Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ace906539f | ||
|
|
f039fdf8e9 |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
- Repo: https://github.com/clawdbot/clawdbot
|
- Repo: https://github.com/clawdbot/clawdbot
|
||||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||||
@@ -84,7 +84,6 @@
|
|||||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||||
- Never update the Carbon dependency.
|
- Never update the Carbon dependency.
|
||||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
|
||||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||||
|
|||||||
+4
-43
@@ -2,14 +2,6 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.18-5
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Plugins: auto-select exclusive slots when enabling/installing plugins. (#1181) — thanks @sebslight.
|
|
||||||
|
|
||||||
## 2026.1.18-4
|
## 2026.1.18-4
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
@@ -18,20 +10,14 @@ Docs: https://docs.clawd.bot
|
|||||||
- Swabble: use the tagged Commander Swift package release.
|
- Swabble: use the tagged Commander Swift package release.
|
||||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
- Memory: add native Gemini embeddings provider for memory search. (#1151) — thanks @gumadeiras.
|
||||||
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
|
||||||
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
|
||||||
- Memory: add native Gemini embeddings provider for memory search. (#1151)
|
|
||||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
|
||||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
||||||
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
|
||||||
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
|
|
||||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151) — thanks @gumadeiras.
|
||||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) — thanks @gumadeiras.
|
||||||
|
- Agents: surface tool failures when no assistant output is emitted. (#1175) — thanks @vrknetha.
|
||||||
|
|
||||||
## 2026.1.18-3
|
## 2026.1.18-3
|
||||||
|
|
||||||
@@ -49,14 +35,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||||
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
||||||
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
||||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
|
||||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
|
||||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
|
||||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
|
||||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
|
||||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
|
||||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
|
||||||
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||||
@@ -69,23 +47,6 @@ Docs: https://docs.clawd.bot
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||||
|
|
||||||
## 2026.1.18-1
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
|
||||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
|
||||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
|
||||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
|
||||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
|
||||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
|
||||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
|
||||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
|
||||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
|
||||||
- Discord: only emit slow listener warnings after 30s.
|
|
||||||
|
|
||||||
## 2026.1.17-6
|
## 2026.1.17-6
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
"location" : "https://github.com/steipete/Peekaboo.git",
|
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
|
"revision" : "b2d0384d9f0f45b945d5f718f8a865bd574d83c2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
# Clawdbot macOS app (dev + signing)
|
|
||||||
|
|
||||||
## Quick dev run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# from repo root
|
|
||||||
scripts/restart-mac.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Options:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
|
|
||||||
scripts/restart-mac.sh --sign # force code signing (requires cert)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Packaging flow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/package-mac-app.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`.
|
|
||||||
|
|
||||||
## Signing behavior
|
|
||||||
|
|
||||||
Auto-selects identity (first match):
|
|
||||||
1) Developer ID Application
|
|
||||||
2) Apple Distribution
|
|
||||||
3) Apple Development
|
|
||||||
4) first available identity
|
|
||||||
|
|
||||||
If none found:
|
|
||||||
- errors by default
|
|
||||||
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
|
|
||||||
|
|
||||||
## Team ID audit (Sparkle mismatch guard)
|
|
||||||
|
|
||||||
After signing, we read the app bundle Team ID and compare every Mach-O inside the app.
|
|
||||||
If any embedded binary has a different Team ID, signing fails.
|
|
||||||
|
|
||||||
Skip the audit:
|
|
||||||
```bash
|
|
||||||
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Library validation workaround (dev only)
|
|
||||||
|
|
||||||
If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This adds `com.apple.security.cs.disable-library-validation` to app entitlements.
|
|
||||||
Use for local dev only; keep off for release builds.
|
|
||||||
|
|
||||||
## Useful env flags
|
|
||||||
|
|
||||||
- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`
|
|
||||||
- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist)
|
|
||||||
- `CODESIGN_TIMESTAMP=off` (offline debug)
|
|
||||||
- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround)
|
|
||||||
- `SKIP_TEAM_ID_CHECK=1` (bypass audit)
|
|
||||||
@@ -8,8 +8,6 @@ struct BridgeNodeInfo: Sendable {
|
|||||||
var displayName: String?
|
var displayName: String?
|
||||||
var platform: String?
|
var platform: String?
|
||||||
var version: String?
|
var version: String?
|
||||||
var coreVersion: String?
|
|
||||||
var uiVersion: String?
|
|
||||||
var deviceFamily: String?
|
var deviceFamily: String?
|
||||||
var modelIdentifier: String?
|
var modelIdentifier: String?
|
||||||
var remoteAddress: String?
|
var remoteAddress: String?
|
||||||
@@ -149,8 +147,6 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
platform: hello.platform,
|
platform: hello.platform,
|
||||||
version: hello.version,
|
version: hello.version,
|
||||||
coreVersion: hello.coreVersion,
|
|
||||||
uiVersion: hello.uiVersion,
|
|
||||||
deviceFamily: hello.deviceFamily,
|
deviceFamily: hello.deviceFamily,
|
||||||
modelIdentifier: hello.modelIdentifier,
|
modelIdentifier: hello.modelIdentifier,
|
||||||
remoteAddress: self.remoteAddressString(),
|
remoteAddress: self.remoteAddressString(),
|
||||||
@@ -175,8 +171,6 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: req.displayName,
|
displayName: req.displayName,
|
||||||
platform: req.platform,
|
platform: req.platform,
|
||||||
version: req.version,
|
version: req.version,
|
||||||
coreVersion: req.coreVersion,
|
|
||||||
uiVersion: req.uiVersion,
|
|
||||||
deviceFamily: req.deviceFamily,
|
deviceFamily: req.deviceFamily,
|
||||||
modelIdentifier: req.modelIdentifier,
|
modelIdentifier: req.modelIdentifier,
|
||||||
caps: req.caps,
|
caps: req.caps,
|
||||||
@@ -192,8 +186,6 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: enriched.displayName,
|
displayName: enriched.displayName,
|
||||||
platform: enriched.platform,
|
platform: enriched.platform,
|
||||||
version: enriched.version,
|
version: enriched.version,
|
||||||
coreVersion: enriched.coreVersion,
|
|
||||||
uiVersion: enriched.uiVersion,
|
|
||||||
deviceFamily: enriched.deviceFamily,
|
deviceFamily: enriched.deviceFamily,
|
||||||
modelIdentifier: enriched.modelIdentifier,
|
modelIdentifier: enriched.modelIdentifier,
|
||||||
remoteAddress: enriched.remoteAddress,
|
remoteAddress: enriched.remoteAddress,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import CryptoKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import Security
|
import Security
|
||||||
@@ -122,13 +121,6 @@ struct ExecApprovalsFile: Codable {
|
|||||||
var agents: [String: ExecApprovalsAgent]?
|
var agents: [String: ExecApprovalsAgent]?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecApprovalsSnapshot: Codable {
|
|
||||||
var path: String
|
|
||||||
var exists: Bool
|
|
||||||
var hash: String
|
|
||||||
var file: ExecApprovalsFile
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecApprovalsResolved {
|
struct ExecApprovalsResolved {
|
||||||
let url: URL
|
let url: URL
|
||||||
let socketPath: String
|
let socketPath: String
|
||||||
@@ -161,58 +153,6 @@ enum ExecApprovalsStore {
|
|||||||
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
||||||
}
|
}
|
||||||
|
|
||||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
|
||||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
return ExecApprovalsFile(
|
|
||||||
version: 1,
|
|
||||||
socket: ExecApprovalsSocketConfig(
|
|
||||||
path: socketPath.isEmpty ? nil : socketPath,
|
|
||||||
token: token.isEmpty ? nil : token),
|
|
||||||
defaults: file.defaults,
|
|
||||||
agents: file.agents)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
|
||||||
let url = self.fileURL()
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
|
||||||
return ExecApprovalsSnapshot(
|
|
||||||
path: url.path,
|
|
||||||
exists: false,
|
|
||||||
hash: self.hashRaw(nil),
|
|
||||||
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
|
|
||||||
}
|
|
||||||
let raw = try? String(contentsOf: url, encoding: .utf8)
|
|
||||||
let data = raw.flatMap { $0.data(using: .utf8) }
|
|
||||||
let decoded: ExecApprovalsFile = {
|
|
||||||
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 {
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
|
||||||
}()
|
|
||||||
return ExecApprovalsSnapshot(
|
|
||||||
path: url.path,
|
|
||||||
exists: true,
|
|
||||||
hash: self.hashRaw(raw),
|
|
||||||
file: decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
|
||||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if socketPath.isEmpty {
|
|
||||||
return ExecApprovalsFile(
|
|
||||||
version: file.version,
|
|
||||||
socket: nil,
|
|
||||||
defaults: file.defaults,
|
|
||||||
agents: file.agents)
|
|
||||||
}
|
|
||||||
return ExecApprovalsFile(
|
|
||||||
version: file.version,
|
|
||||||
socket: ExecApprovalsSocketConfig(path: socketPath, token: nil),
|
|
||||||
defaults: file.defaults,
|
|
||||||
agents: file.agents)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func loadFile() -> ExecApprovalsFile {
|
static func loadFile() -> ExecApprovalsFile {
|
||||||
let url = self.fileURL()
|
let url = self.fileURL()
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
@@ -432,12 +372,6 @@ enum ExecApprovalsStore {
|
|||||||
return UUID().uuidString
|
return UUID().uuidString
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func hashRaw(_ raw: String?) -> String {
|
|
||||||
let data = Data((raw ?? "").utf8)
|
|
||||||
let digest = SHA256.hash(data: data)
|
|
||||||
return digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func expandPath(_ raw: String) -> String {
|
private static func expandPath(_ raw: String) -> String {
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed == "~" {
|
if trimmed == "~" {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import CryptoKit
|
|
||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
@@ -28,49 +27,6 @@ private struct ExecApprovalSocketDecision: Codable {
|
|||||||
var decision: ExecApprovalDecision
|
var decision: ExecApprovalDecision
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate struct ExecHostSocketRequest: Codable {
|
|
||||||
var type: String
|
|
||||||
var id: String
|
|
||||||
var nonce: String
|
|
||||||
var ts: Int
|
|
||||||
var hmac: String
|
|
||||||
var requestJson: String
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct ExecHostRequest: Codable {
|
|
||||||
var command: [String]
|
|
||||||
var rawCommand: String?
|
|
||||||
var cwd: String?
|
|
||||||
var env: [String: String]?
|
|
||||||
var timeoutMs: Int?
|
|
||||||
var needsScreenRecording: Bool?
|
|
||||||
var agentId: String?
|
|
||||||
var sessionKey: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct ExecHostRunResult: Codable {
|
|
||||||
var exitCode: Int?
|
|
||||||
var timedOut: Bool
|
|
||||||
var success: Bool
|
|
||||||
var stdout: String
|
|
||||||
var stderr: String
|
|
||||||
var error: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct ExecHostError: Codable {
|
|
||||||
var code: String
|
|
||||||
var message: String
|
|
||||||
var reason: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct ExecHostResponse: Codable {
|
|
||||||
var type: String
|
|
||||||
var id: String
|
|
||||||
var ok: Bool
|
|
||||||
var payload: ExecHostRunResult?
|
|
||||||
var error: ExecHostError?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExecApprovalsSocketClient {
|
enum ExecApprovalsSocketClient {
|
||||||
private struct TimeoutError: LocalizedError {
|
private struct TimeoutError: LocalizedError {
|
||||||
var message: String
|
var message: String
|
||||||
@@ -190,9 +146,6 @@ final class ExecApprovalsPromptServer {
|
|||||||
token: approvals.token,
|
token: approvals.token,
|
||||||
onPrompt: { request in
|
onPrompt: { request in
|
||||||
await ExecApprovalsPromptPresenter.prompt(request)
|
await ExecApprovalsPromptPresenter.prompt(request)
|
||||||
},
|
|
||||||
onExec: { request in
|
|
||||||
await ExecHostExecutor.handle(request)
|
|
||||||
})
|
})
|
||||||
server.start()
|
server.start()
|
||||||
self.server = server
|
self.server = server
|
||||||
@@ -253,182 +206,11 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
fileprivate enum ExecHostExecutor {
|
|
||||||
private static let blockedEnvKeys: Set<String> = [
|
|
||||||
"PATH",
|
|
||||||
"NODE_OPTIONS",
|
|
||||||
"PYTHONHOME",
|
|
||||||
"PYTHONPATH",
|
|
||||||
"PERL5LIB",
|
|
||||||
"PERL5OPT",
|
|
||||||
"RUBYOPT",
|
|
||||||
]
|
|
||||||
|
|
||||||
private static let blockedEnvPrefixes: [String] = [
|
|
||||||
"DYLD_",
|
|
||||||
"LD_",
|
|
||||||
]
|
|
||||||
|
|
||||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
|
||||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
guard !command.isEmpty else {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
|
|
||||||
}
|
|
||||||
|
|
||||||
let displayCommand = ExecCommandFormatter.displayString(
|
|
||||||
for: command,
|
|
||||||
rawCommand: request.rawCommand)
|
|
||||||
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
|
|
||||||
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
|
|
||||||
let security = approvals.agent.security
|
|
||||||
let ask = approvals.agent.ask
|
|
||||||
let autoAllowSkills = approvals.agent.autoAllowSkills
|
|
||||||
let env = self.sanitizedEnv(request.env)
|
|
||||||
let resolution = ExecCommandResolution.resolve(
|
|
||||||
command: command,
|
|
||||||
rawCommand: request.rawCommand,
|
|
||||||
cwd: request.cwd,
|
|
||||||
env: env)
|
|
||||||
let allowlistMatch = security == .allowlist
|
|
||||||
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
|
||||||
: nil
|
|
||||||
let skillAllow: Bool
|
|
||||||
if autoAllowSkills, let name = resolution?.executableName {
|
|
||||||
let bins = await SkillBinsCache.shared.currentBins()
|
|
||||||
skillAllow = bins.contains(name)
|
|
||||||
} else {
|
|
||||||
skillAllow = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if security == .deny {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny", reason: "security=deny"))
|
|
||||||
}
|
|
||||||
|
|
||||||
let requiresAsk: Bool = {
|
|
||||||
if ask == .always { return true }
|
|
||||||
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
|
|
||||||
var approvedByAsk = false
|
|
||||||
if requiresAsk {
|
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
|
||||||
ExecApprovalPromptRequest(
|
|
||||||
command: displayCommand,
|
|
||||||
cwd: request.cwd,
|
|
||||||
host: "node",
|
|
||||||
security: security.rawValue,
|
|
||||||
ask: ask.rawValue,
|
|
||||||
agentId: trimmedAgent,
|
|
||||||
resolvedPath: resolution?.resolvedPath))
|
|
||||||
|
|
||||||
switch decision {
|
|
||||||
case .deny:
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied", reason: "user-denied"))
|
|
||||||
case .allowAlways:
|
|
||||||
approvedByAsk = true
|
|
||||||
if security == .allowlist {
|
|
||||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
|
||||||
if !pattern.isEmpty {
|
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .allowOnce:
|
|
||||||
approvedByAsk = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss", reason: "allowlist-miss"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let match = allowlistMatch {
|
|
||||||
ExecApprovalsStore.recordAllowlistUse(
|
|
||||||
agentId: trimmedAgent,
|
|
||||||
pattern: match.pattern,
|
|
||||||
command: displayCommand,
|
|
||||||
resolvedPath: resolution?.resolvedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.needsScreenRecording == true {
|
|
||||||
let authorized = await PermissionManager
|
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
|
||||||
if !authorized {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording", reason: "permission:screenRecording"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
|
|
||||||
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
|
||||||
await ShellExecutor.runDetailed(
|
|
||||||
command: command,
|
|
||||||
cwd: request.cwd,
|
|
||||||
env: env,
|
|
||||||
timeout: timeoutSec)
|
|
||||||
}.value
|
|
||||||
let payload = ExecHostRunResult(
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
timedOut: result.timedOut,
|
|
||||||
success: result.success,
|
|
||||||
stdout: result.stdout,
|
|
||||||
stderr: result.stderr,
|
|
||||||
error: result.errorMessage)
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: true,
|
|
||||||
payload: payload,
|
|
||||||
error: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
|
||||||
guard let overrides else { return nil }
|
|
||||||
var merged = ProcessInfo.processInfo.environment
|
|
||||||
for (rawKey, value) in overrides {
|
|
||||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !key.isEmpty else { continue }
|
|
||||||
let upper = key.uppercased()
|
|
||||||
if self.blockedEnvKeys.contains(upper) { continue }
|
|
||||||
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
|
||||||
merged[key] = value
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
||||||
private let socketPath: String
|
private let socketPath: String
|
||||||
private let token: String
|
private let token: String
|
||||||
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
||||||
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
|
|
||||||
private var socketFD: Int32 = -1
|
private var socketFD: Int32 = -1
|
||||||
private var acceptTask: Task<Void, Never>?
|
private var acceptTask: Task<Void, Never>?
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
@@ -436,13 +218,11 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
init(
|
init(
|
||||||
socketPath: String,
|
socketPath: String,
|
||||||
token: String,
|
token: String,
|
||||||
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
|
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
|
||||||
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
|
|
||||||
{
|
{
|
||||||
self.socketPath = socketPath
|
self.socketPath = socketPath
|
||||||
self.token = token
|
self.token = token
|
||||||
self.onPrompt = onPrompt
|
self.onPrompt = onPrompt
|
||||||
self.onExec = onExec
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -537,39 +317,26 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
private func handleClient(fd: Int32) async {
|
private func handleClient(fd: Int32) async {
|
||||||
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
do {
|
do {
|
||||||
guard self.isAllowedPeer(fd: fd) else {
|
|
||||||
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||||
let data = line.data(using: .utf8)
|
let data = line.data(using: .utf8)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard
|
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
||||||
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
guard request.type == "request", request.token == self.token else {
|
||||||
let type = envelope["type"] as? String
|
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
|
||||||
else {
|
let data = try JSONEncoder().encode(response)
|
||||||
return
|
var payload = data
|
||||||
}
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
if type == "request" {
|
|
||||||
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
|
||||||
guard request.token == self.token else {
|
|
||||||
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let decision = await self.onPrompt(request.request)
|
|
||||||
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if type == "exec" {
|
|
||||||
let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data)
|
|
||||||
let response = await self.handleExecRequest(request)
|
|
||||||
try self.sendExecResponse(handle: handle, response: response)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let decision = await self.onPrompt(request.request)
|
||||||
|
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
|
||||||
|
let responseData = try JSONEncoder().encode(response)
|
||||||
|
var payload = responseData
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
@@ -590,77 +357,4 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||||
return String(data: lineData, encoding: .utf8)
|
return String(data: lineData, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendApprovalResponse(
|
|
||||||
handle: FileHandle,
|
|
||||||
id: String,
|
|
||||||
decision: ExecApprovalDecision) throws
|
|
||||||
{
|
|
||||||
let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision)
|
|
||||||
let data = try JSONEncoder().encode(response)
|
|
||||||
var payload = data
|
|
||||||
payload.append(0x0A)
|
|
||||||
try handle.write(contentsOf: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws {
|
|
||||||
let data = try JSONEncoder().encode(response)
|
|
||||||
var payload = data
|
|
||||||
payload.append(0x0A)
|
|
||||||
try handle.write(contentsOf: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isAllowedPeer(fd: Int32) -> Bool {
|
|
||||||
var uid = uid_t(0)
|
|
||||||
var gid = gid_t(0)
|
|
||||||
if getpeereid(fd, &uid, &gid) != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return uid == geteuid()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse {
|
|
||||||
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
|
|
||||||
if abs(nowMs - request.ts) > 10_000 {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: request.id,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl"))
|
|
||||||
}
|
|
||||||
let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson)
|
|
||||||
if expected != request.hmac {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: request.id,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac"))
|
|
||||||
}
|
|
||||||
guard let requestData = request.requestJson.data(using: .utf8),
|
|
||||||
let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData)
|
|
||||||
else {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: request.id,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json"))
|
|
||||||
}
|
|
||||||
let response = await self.onExec(payload)
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: request.id,
|
|
||||||
ok: response.ok,
|
|
||||||
payload: response.payload,
|
|
||||||
error: response.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String {
|
|
||||||
let key = SymmetricKey(data: Data(self.token.utf8))
|
|
||||||
let message = "\(nonce):\(ts):\(requestJson)"
|
|
||||||
let mac = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
|
|
||||||
return mac.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -249,13 +249,6 @@ actor GatewayConnection {
|
|||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
func cachedGatewayVersion() -> String? {
|
|
||||||
guard let snapshot = self.lastSnapshot else { return nil }
|
|
||||||
let raw = snapshot.server["version"]?.value as? String
|
|
||||||
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
||||||
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||||
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@@ -280,7 +280,9 @@ extension MenuSessionsInjector {
|
|||||||
|
|
||||||
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||||
let rows = self.usageRows
|
let rows = self.usageRows
|
||||||
if rows.isEmpty {
|
let errorText = self.cachedUsageErrorText
|
||||||
|
|
||||||
|
if rows.isEmpty, errorText == nil {
|
||||||
return cursor
|
return cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +306,25 @@ extension MenuSessionsInjector {
|
|||||||
menu.insertItem(headerItem, at: cursor)
|
menu.insertItem(headerItem, at: cursor)
|
||||||
cursor += 1
|
cursor += 1
|
||||||
|
|
||||||
|
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
|
||||||
|
menu.insertItem(
|
||||||
|
self.makeMessageItem(
|
||||||
|
text: errorText,
|
||||||
|
symbolName: "exclamationmark.triangle",
|
||||||
|
width: width,
|
||||||
|
maxLines: 2),
|
||||||
|
at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.isEmpty {
|
||||||
|
menu.insertItem(
|
||||||
|
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
|
||||||
|
at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
if let selectedProvider = self.selectedUsageProviderId,
|
if let selectedProvider = self.selectedUsageProviderId,
|
||||||
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
|
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
|
||||||
rows.count > 1
|
rows.count > 1
|
||||||
@@ -419,8 +440,6 @@ extension MenuSessionsInjector {
|
|||||||
displayName: "Gateway",
|
displayName: "Gateway",
|
||||||
platform: platform,
|
platform: platform,
|
||||||
version: nil,
|
version: nil,
|
||||||
coreVersion: nil,
|
|
||||||
uiVersion: nil,
|
|
||||||
deviceFamily: nil,
|
deviceFamily: nil,
|
||||||
modelIdentifier: nil,
|
modelIdentifier: nil,
|
||||||
remoteIp: host,
|
remoteIp: host,
|
||||||
@@ -540,11 +559,14 @@ extension MenuSessionsInjector {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
||||||
} catch {
|
|
||||||
self.cachedUsageSummary = nil
|
|
||||||
self.cachedUsageErrorText = nil
|
self.cachedUsageErrorText = nil
|
||||||
|
self.usageCacheUpdatedAt = Date()
|
||||||
|
} catch {
|
||||||
|
if self.cachedUsageSummary == nil {
|
||||||
|
self.cachedUsageErrorText = self.compactUsageError(error)
|
||||||
|
}
|
||||||
|
self.usageCacheUpdatedAt = Date()
|
||||||
}
|
}
|
||||||
self.usageCacheUpdatedAt = Date()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func compactUsageError(_ error: Error) -> String {
|
private func compactUsageError(_ error: Error) -> String {
|
||||||
@@ -725,8 +747,8 @@ extension MenuSessionsInjector {
|
|||||||
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty {
|
if let version = entry.version?.nonEmpty {
|
||||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: version))
|
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
||||||
|
|||||||
@@ -95,8 +95,6 @@ actor MacNodeBridgePairingClient {
|
|||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
platform: hello.platform,
|
platform: hello.platform,
|
||||||
version: hello.version,
|
version: hello.version,
|
||||||
coreVersion: hello.coreVersion,
|
|
||||||
uiVersion: hello.uiVersion,
|
|
||||||
deviceFamily: hello.deviceFamily,
|
deviceFamily: hello.deviceFamily,
|
||||||
modelIdentifier: hello.modelIdentifier,
|
modelIdentifier: hello.modelIdentifier,
|
||||||
caps: hello.caps,
|
caps: hello.caps,
|
||||||
|
|||||||
@@ -114,19 +114,12 @@ final class MacNodeModeCoordinator {
|
|||||||
let caps = self.currentCaps()
|
let caps = self.currentCaps()
|
||||||
let commands = self.currentCommands(caps: caps)
|
let commands = self.currentCommands(caps: caps)
|
||||||
let permissions = await self.currentPermissions()
|
let permissions = await self.currentPermissions()
|
||||||
let uiVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
|
||||||
let liveGatewayVersion = await GatewayConnection.shared.cachedGatewayVersion()
|
|
||||||
let fallbackGatewayVersion = GatewayProcessManager.shared.environmentStatus.gatewayVersion
|
|
||||||
let coreVersion = (liveGatewayVersion ?? fallbackGatewayVersion)?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return BridgeHello(
|
return BridgeHello(
|
||||||
nodeId: Self.nodeId(),
|
nodeId: Self.nodeId(),
|
||||||
displayName: InstanceIdentity.displayName,
|
displayName: InstanceIdentity.displayName,
|
||||||
token: token,
|
token: token,
|
||||||
platform: "macos",
|
platform: "macos",
|
||||||
version: uiVersion,
|
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
||||||
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
|
|
||||||
uiVersion: uiVersion,
|
|
||||||
deviceFamily: "Mac",
|
deviceFamily: "Mac",
|
||||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||||
caps: caps,
|
caps: caps,
|
||||||
@@ -165,8 +158,6 @@ final class MacNodeModeCoordinator {
|
|||||||
ClawdbotSystemCommand.notify.rawValue,
|
ClawdbotSystemCommand.notify.rawValue,
|
||||||
ClawdbotSystemCommand.which.rawValue,
|
ClawdbotSystemCommand.which.rawValue,
|
||||||
ClawdbotSystemCommand.run.rawValue,
|
ClawdbotSystemCommand.run.rawValue,
|
||||||
ClawdbotSystemCommand.execApprovalsGet.rawValue,
|
|
||||||
ClawdbotSystemCommand.execApprovalsSet.rawValue,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
let capsSet = Set(caps)
|
let capsSet = Set(caps)
|
||||||
|
|||||||
@@ -64,10 +64,6 @@ actor MacNodeRuntime {
|
|||||||
return try await self.handleSystemWhich(req)
|
return try await self.handleSystemWhich(req)
|
||||||
case ClawdbotSystemCommand.notify.rawValue:
|
case ClawdbotSystemCommand.notify.rawValue:
|
||||||
return try await self.handleSystemNotify(req)
|
return try await self.handleSystemNotify(req)
|
||||||
case ClawdbotSystemCommand.execApprovalsGet.rawValue:
|
|
||||||
return try await self.handleSystemExecApprovalsGet(req)
|
|
||||||
case ClawdbotSystemCommand.execApprovalsSet.rawValue:
|
|
||||||
return try await self.handleSystemExecApprovalsSet(req)
|
|
||||||
default:
|
default:
|
||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
||||||
}
|
}
|
||||||
@@ -680,72 +676,6 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
||||||
_ = ExecApprovalsStore.ensureFile()
|
|
||||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
|
||||||
let redacted = ExecApprovalsSnapshot(
|
|
||||||
path: snapshot.path,
|
|
||||||
exists: snapshot.exists,
|
|
||||||
hash: snapshot.hash,
|
|
||||||
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
|
|
||||||
let payload = try Self.encodePayload(redacted)
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
||||||
struct SetParams: Decodable {
|
|
||||||
var file: ExecApprovalsFile
|
|
||||||
var baseHash: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
|
|
||||||
let current = ExecApprovalsStore.ensureFile()
|
|
||||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
|
||||||
if snapshot.exists {
|
|
||||||
if snapshot.hash.isEmpty {
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .invalidRequest,
|
|
||||||
message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry")
|
|
||||||
}
|
|
||||||
let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if baseHash.isEmpty {
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .invalidRequest,
|
|
||||||
message: "INVALID_REQUEST: exec approvals base hash required; reload and retry")
|
|
||||||
}
|
|
||||||
if baseHash != snapshot.hash {
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .invalidRequest,
|
|
||||||
message: "INVALID_REQUEST: exec approvals changed; reload and retry")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var normalized = ExecApprovalsStore.normalizeIncoming(params.file)
|
|
||||||
let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let resolvedPath = (socketPath?.isEmpty == false)
|
|
||||||
? socketPath!
|
|
||||||
: current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ??
|
|
||||||
ExecApprovalsStore.socketPath()
|
|
||||||
let resolvedToken = (token?.isEmpty == false)
|
|
||||||
? token!
|
|
||||||
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
|
|
||||||
|
|
||||||
ExecApprovalsStore.saveFile(normalized)
|
|
||||||
let nextSnapshot = ExecApprovalsStore.readSnapshot()
|
|
||||||
let redacted = ExecApprovalsSnapshot(
|
|
||||||
path: nextSnapshot.path,
|
|
||||||
exists: nextSnapshot.exists,
|
|
||||||
hash: nextSnapshot.hash,
|
|
||||||
file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file))
|
|
||||||
let payload = try Self.encodePayload(redacted)
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
||||||
guard let sender = self.eventSender else { return }
|
guard let sender = self.eventSender else { return }
|
||||||
guard let data = try? JSONEncoder().encode(payload),
|
guard let data = try? JSONEncoder().encode(payload),
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ struct NodeMenuEntryFormatter {
|
|||||||
if let platform = self.platformText(entry) {
|
if let platform = self.platformText(entry) {
|
||||||
parts.append("platform \(platform)")
|
parts.append("platform \(platform)")
|
||||||
}
|
}
|
||||||
let versionLabels = self.versionLabels(entry)
|
if let version = entry.version?.nonEmpty {
|
||||||
if !versionLabels.isEmpty {
|
parts.append("app \(self.compactVersion(version))")
|
||||||
parts.append(versionLabels.joined(separator: " · "))
|
|
||||||
}
|
}
|
||||||
parts.append("status \(self.roleText(entry))")
|
parts.append("status \(self.roleText(entry))")
|
||||||
return parts.joined(separator: " · ")
|
return parts.joined(separator: " · ")
|
||||||
@@ -61,9 +60,8 @@ struct NodeMenuEntryFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
||||||
let labels = self.versionLabels(entry, compact: false)
|
guard let version = entry.version?.nonEmpty else { return nil }
|
||||||
if labels.isEmpty { return nil }
|
return self.shortVersionLabel(version)
|
||||||
return labels.joined(separator: " · ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func platformText(_ entry: NodeInfo) -> String? {
|
static func platformText(_ entry: NodeInfo) -> String? {
|
||||||
@@ -129,39 +127,6 @@ struct NodeMenuEntryFormatter {
|
|||||||
return compact
|
return compact
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] {
|
|
||||||
let (core, ui) = self.resolveVersions(entry)
|
|
||||||
var labels: [String] = []
|
|
||||||
if let core {
|
|
||||||
let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core)
|
|
||||||
labels.append("core \(label)")
|
|
||||||
}
|
|
||||||
if let ui {
|
|
||||||
let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui)
|
|
||||||
labels.append("ui \(label)")
|
|
||||||
}
|
|
||||||
return labels
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) {
|
|
||||||
let core = entry.coreVersion?.nonEmpty
|
|
||||||
let ui = entry.uiVersion?.nonEmpty
|
|
||||||
if core != nil || ui != nil {
|
|
||||||
return (core, ui)
|
|
||||||
}
|
|
||||||
guard let legacy = entry.version?.nonEmpty else { return (nil, nil) }
|
|
||||||
if self.isHeadlessPlatform(entry) {
|
|
||||||
return (legacy, nil)
|
|
||||||
}
|
|
||||||
return (nil, legacy)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool {
|
|
||||||
let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
|
||||||
if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
||||||
if self.isGateway(entry) {
|
if self.isGateway(entry) {
|
||||||
return self.safeSystemSymbol(
|
return self.safeSystemSymbol(
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ struct NodeInfo: Identifiable, Codable {
|
|||||||
let displayName: String?
|
let displayName: String?
|
||||||
let platform: String?
|
let platform: String?
|
||||||
let version: String?
|
let version: String?
|
||||||
let coreVersion: String?
|
|
||||||
let uiVersion: String?
|
|
||||||
let deviceFamily: String?
|
let deviceFamily: String?
|
||||||
let modelIdentifier: String?
|
let modelIdentifier: String?
|
||||||
let remoteIp: String?
|
let remoteIp: String?
|
||||||
|
|||||||
@@ -530,8 +530,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
public let displayname: String?
|
public let displayname: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
public let coreversion: String?
|
|
||||||
public let uiversion: String?
|
|
||||||
public let devicefamily: String?
|
public let devicefamily: String?
|
||||||
public let modelidentifier: String?
|
public let modelidentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -544,8 +542,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
displayname: String?,
|
displayname: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
coreversion: String?,
|
|
||||||
uiversion: String?,
|
|
||||||
devicefamily: String?,
|
devicefamily: String?,
|
||||||
modelidentifier: String?,
|
modelidentifier: String?,
|
||||||
caps: [String]?,
|
caps: [String]?,
|
||||||
@@ -557,8 +553,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
self.displayname = displayname
|
self.displayname = displayname
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
self.coreversion = coreversion
|
|
||||||
self.uiversion = uiversion
|
|
||||||
self.devicefamily = devicefamily
|
self.devicefamily = devicefamily
|
||||||
self.modelidentifier = modelidentifier
|
self.modelidentifier = modelidentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
@@ -571,8 +565,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
case displayname = "displayName"
|
case displayname = "displayName"
|
||||||
case platform
|
case platform
|
||||||
case version
|
case version
|
||||||
case coreversion = "coreVersion"
|
|
||||||
case uiversion = "uiVersion"
|
|
||||||
case devicefamily = "deviceFamily"
|
case devicefamily = "deviceFamily"
|
||||||
case modelidentifier = "modelIdentifier"
|
case modelidentifier = "modelIdentifier"
|
||||||
case caps
|
case caps
|
||||||
@@ -1660,40 +1652,6 @@ public struct ExecApprovalsSetParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ExecApprovalsNodeGetParams: Codable, Sendable {
|
|
||||||
public let nodeid: String
|
|
||||||
|
|
||||||
public init(
|
|
||||||
nodeid: String
|
|
||||||
) {
|
|
||||||
self.nodeid = nodeid
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case nodeid = "nodeId"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ExecApprovalsNodeSetParams: Codable, Sendable {
|
|
||||||
public let nodeid: String
|
|
||||||
public let file: [String: AnyCodable]
|
|
||||||
public let basehash: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
nodeid: String,
|
|
||||||
file: [String: AnyCodable],
|
|
||||||
basehash: String?
|
|
||||||
) {
|
|
||||||
self.nodeid = nodeid
|
|
||||||
self.file = file
|
|
||||||
self.basehash = basehash
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case nodeid = "nodeId"
|
|
||||||
case file
|
|
||||||
case basehash = "baseHash"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ExecApprovalsSnapshot: Codable, Sendable {
|
public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||||
public let path: String
|
public let path: String
|
||||||
public let exists: Bool
|
public let exists: Bool
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
public let token: String?
|
public let token: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
public let coreVersion: String?
|
|
||||||
public let uiVersion: String?
|
|
||||||
public let deviceFamily: String?
|
public let deviceFamily: String?
|
||||||
public let modelIdentifier: String?
|
public let modelIdentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -78,8 +76,6 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
token: String?,
|
token: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
coreVersion: String? = nil,
|
|
||||||
uiVersion: String? = nil,
|
|
||||||
deviceFamily: String? = nil,
|
deviceFamily: String? = nil,
|
||||||
modelIdentifier: String? = nil,
|
modelIdentifier: String? = nil,
|
||||||
caps: [String]? = nil,
|
caps: [String]? = nil,
|
||||||
@@ -92,8 +88,6 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
self.token = token
|
self.token = token
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
self.coreVersion = coreVersion
|
|
||||||
self.uiVersion = uiVersion
|
|
||||||
self.deviceFamily = deviceFamily
|
self.deviceFamily = deviceFamily
|
||||||
self.modelIdentifier = modelIdentifier
|
self.modelIdentifier = modelIdentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
@@ -127,8 +121,6 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
public let displayName: String?
|
public let displayName: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
public let coreVersion: String?
|
|
||||||
public let uiVersion: String?
|
|
||||||
public let deviceFamily: String?
|
public let deviceFamily: String?
|
||||||
public let modelIdentifier: String?
|
public let modelIdentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -143,8 +135,6 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
displayName: String?,
|
displayName: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
coreVersion: String? = nil,
|
|
||||||
uiVersion: String? = nil,
|
|
||||||
deviceFamily: String? = nil,
|
deviceFamily: String? = nil,
|
||||||
modelIdentifier: String? = nil,
|
modelIdentifier: String? = nil,
|
||||||
caps: [String]? = nil,
|
caps: [String]? = nil,
|
||||||
@@ -158,8 +148,6 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
self.coreVersion = coreVersion
|
|
||||||
self.uiVersion = uiVersion
|
|
||||||
self.deviceFamily = deviceFamily
|
self.deviceFamily = deviceFamily
|
||||||
self.modelIdentifier = modelIdentifier
|
self.modelIdentifier = modelIdentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
|||||||
case run = "system.run"
|
case run = "system.run"
|
||||||
case which = "system.which"
|
case which = "system.which"
|
||||||
case notify = "system.notify"
|
case notify = "system.notify"
|
||||||
case execApprovalsGet = "system.execApprovals.get"
|
|
||||||
case execApprovalsSet = "system.execApprovals.set"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
||||||
|
|||||||
+10
-45
@@ -1,13 +1,11 @@
|
|||||||
---
|
---
|
||||||
summary: "Slack setup for socket or HTTP webhook mode"
|
summary: "Slack socket mode setup and Clawdbot config"
|
||||||
read_when: "Setting up Slack or debugging Slack socket/HTTP mode"
|
read_when: "Setting up Slack or debugging Slack socket mode"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Slack
|
# Slack (socket mode)
|
||||||
|
|
||||||
## Socket mode (default)
|
## Quick setup (beginner)
|
||||||
|
|
||||||
### Quick setup (beginner)
|
|
||||||
1) Create a Slack app and enable **Socket Mode**.
|
1) Create a Slack app and enable **Socket Mode**.
|
||||||
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
||||||
3) Set tokens for Clawdbot and start the gateway.
|
3) Set tokens for Clawdbot and start the gateway.
|
||||||
@@ -25,7 +23,7 @@ Minimal config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup
|
## Setup
|
||||||
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
||||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||||
@@ -45,7 +43,7 @@ Use the manifest below so scopes and events stay in sync.
|
|||||||
|
|
||||||
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||||
|
|
||||||
### Clawdbot config (minimal)
|
## Clawdbot config (minimal)
|
||||||
|
|
||||||
Set tokens via env vars (recommended):
|
Set tokens via env vars (recommended):
|
||||||
- `SLACK_APP_TOKEN=xapp-...`
|
- `SLACK_APP_TOKEN=xapp-...`
|
||||||
@@ -65,7 +63,7 @@ Or via config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### User token (optional)
|
## User token (optional)
|
||||||
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
||||||
pins, reactions, emoji, member info). By default this stays read-only: reads
|
pins, reactions, emoji, member info). By default this stays read-only: reads
|
||||||
prefer the user token when present, and writes still use the bot token unless
|
prefer the user token when present, and writes still use the bot token unless
|
||||||
@@ -104,51 +102,18 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Token usage
|
### Token usage
|
||||||
- Read operations (history, reactions list, pins list, emoji list, member info,
|
- Read operations (history, reactions list, pins list, emoji list, member info,
|
||||||
search) prefer the user token when configured, otherwise the bot token.
|
search) prefer the user token when configured, otherwise the bot token.
|
||||||
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
||||||
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
||||||
no bot token is available, Clawdbot falls back to the user token.
|
no bot token is available, Clawdbot falls back to the user token.
|
||||||
|
|
||||||
### History context
|
## History context
|
||||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||||
|
|
||||||
## HTTP mode (Events API)
|
## Manifest (optional)
|
||||||
Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments).
|
|
||||||
HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL.
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
1) Create a Slack app and **disable Socket Mode** (optional if you only use HTTP).
|
|
||||||
2) **Basic Information** → copy the **Signing Secret**.
|
|
||||||
3) **OAuth & Permissions** → install the app and copy the **Bot User OAuth Token** (`xoxb-...`).
|
|
||||||
4) **Event Subscriptions** → enable events and set the **Request URL** to your gateway webhook path (default `/slack/events`).
|
|
||||||
5) **Interactivity & Shortcuts** → enable and set the same **Request URL**.
|
|
||||||
6) **Slash Commands** → set the same **Request URL** for your command(s).
|
|
||||||
|
|
||||||
Example request URL:
|
|
||||||
`https://gateway-host/slack/events`
|
|
||||||
|
|
||||||
### Clawdbot config (minimal)
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
enabled: true,
|
|
||||||
mode: "http",
|
|
||||||
botToken: "xoxb-...",
|
|
||||||
signingSecret: "your-signing-secret",
|
|
||||||
webhookPath: "/slack/events"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Multi-account HTTP mode: set `channels.slack.accounts.<id>.mode = "http"` and provide a unique
|
|
||||||
`webhookPath` per account so each Slack app can point to its own URL.
|
|
||||||
|
|
||||||
### Manifest (optional)
|
|
||||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
||||||
user scopes if you plan to configure a user token.
|
user scopes if you plan to configure a user token.
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "CLI reference for `clawdbot approvals` (exec approvals for gateway or node hosts)"
|
|
||||||
read_when:
|
|
||||||
- You want to edit exec approvals from the CLI
|
|
||||||
- You need to manage allowlists on gateway or node hosts
|
|
||||||
---
|
|
||||||
|
|
||||||
# `clawdbot approvals`
|
|
||||||
|
|
||||||
Manage exec approvals for the **gateway host** or a **node host**.
|
|
||||||
By default, commands target the gateway. Use `--node` to edit a node’s approvals.
|
|
||||||
|
|
||||||
Related:
|
|
||||||
- Exec approvals: [Exec approvals](/tools/exec-approvals)
|
|
||||||
- Nodes: [Nodes](/nodes)
|
|
||||||
|
|
||||||
## Common commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot approvals get
|
|
||||||
clawdbot approvals get --node <id|name|ip>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Replace approvals from a file
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot approvals set --file ./exec-approvals.json
|
|
||||||
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Allowlist helpers
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
|
|
||||||
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
|
|
||||||
|
|
||||||
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
|
|
||||||
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
|
||||||
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
|
|
||||||
@@ -9,9 +9,6 @@ read_when:
|
|||||||
|
|
||||||
Manage the Gateway daemon (background service).
|
Manage the Gateway daemon (background service).
|
||||||
|
|
||||||
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
|
|
||||||
as a legacy alias for compatibility.
|
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Gateway CLI: [Gateway](/cli/gateway)
|
- Gateway CLI: [Gateway](/cli/gateway)
|
||||||
- macOS platform notes: [macOS](/platforms/macos)
|
- macOS platform notes: [macOS](/platforms/macos)
|
||||||
|
|||||||
+4
-19
@@ -7,7 +7,7 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot hooks`
|
# `clawdbot hooks`
|
||||||
|
|
||||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, and gateway startup).
|
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Hooks: [Hooks](/hooks)
|
- Hooks: [Hooks](/hooks)
|
||||||
@@ -29,10 +29,9 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
|||||||
**Example output:**
|
**Example output:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Hooks (4/4 ready)
|
Hooks (3/3 ready)
|
||||||
|
|
||||||
Ready:
|
Ready:
|
||||||
🚀 boot-md ✓ - Run BOOT.md on gateway startup
|
|
||||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||||
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
||||||
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
|
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
|
||||||
@@ -108,8 +107,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
|
|||||||
```
|
```
|
||||||
Hooks Status
|
Hooks Status
|
||||||
|
|
||||||
Total hooks: 4
|
Total hooks: 2
|
||||||
Ready: 4
|
Ready: 2
|
||||||
Not ready: 0
|
Not ready: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -274,17 +273,3 @@ clawdbot hooks enable soul-evil
|
|||||||
```
|
```
|
||||||
|
|
||||||
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
||||||
|
|
||||||
### boot-md
|
|
||||||
|
|
||||||
Runs `BOOT.md` when the gateway starts (after channels start).
|
|
||||||
|
|
||||||
**Events**: `gateway:startup`
|
|
||||||
|
|
||||||
**Enable**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks enable boot-md
|
|
||||||
```
|
|
||||||
|
|
||||||
**See:** [boot-md documentation](/hooks#boot-md)
|
|
||||||
+6
-33
@@ -29,13 +29,11 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`sessions`](/cli/sessions)
|
- [`sessions`](/cli/sessions)
|
||||||
- [`gateway`](/cli/gateway)
|
- [`gateway`](/cli/gateway)
|
||||||
- [`daemon`](/cli/daemon)
|
- [`daemon`](/cli/daemon)
|
||||||
- [`service`](/cli/service)
|
|
||||||
- [`logs`](/cli/logs)
|
- [`logs`](/cli/logs)
|
||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
- [`nodes`](/cli/nodes)
|
- [`nodes`](/cli/nodes)
|
||||||
- [`node`](/cli/node)
|
- [`node`](/cli/node)
|
||||||
- [`approvals`](/cli/approvals)
|
|
||||||
- [`sandbox`](/cli/sandbox)
|
- [`sandbox`](/cli/sandbox)
|
||||||
- [`tui`](/cli/tui)
|
- [`tui`](/cli/tui)
|
||||||
- [`browser`](/cli/browser)
|
- [`browser`](/cli/browser)
|
||||||
@@ -145,21 +143,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
start
|
start
|
||||||
stop
|
stop
|
||||||
restart
|
restart
|
||||||
service
|
|
||||||
gateway
|
|
||||||
status
|
|
||||||
install
|
|
||||||
uninstall
|
|
||||||
start
|
|
||||||
stop
|
|
||||||
restart
|
|
||||||
node
|
|
||||||
status
|
|
||||||
install
|
|
||||||
uninstall
|
|
||||||
start
|
|
||||||
stop
|
|
||||||
restart
|
|
||||||
logs
|
logs
|
||||||
models
|
models
|
||||||
list
|
list
|
||||||
@@ -197,10 +180,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
start
|
start
|
||||||
stop
|
stop
|
||||||
restart
|
restart
|
||||||
approvals
|
|
||||||
get
|
|
||||||
set
|
|
||||||
allowlist add|remove
|
|
||||||
browser
|
browser
|
||||||
status
|
status
|
||||||
start
|
start
|
||||||
@@ -541,9 +520,6 @@ Options:
|
|||||||
- `--verbose`
|
- `--verbose`
|
||||||
- `--debug` (alias for `--verbose`)
|
- `--debug` (alias for `--verbose`)
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Overview includes Gateway + Node service status when available.
|
|
||||||
|
|
||||||
### Usage tracking
|
### Usage tracking
|
||||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||||
|
|
||||||
@@ -805,15 +781,12 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
|||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `node start --host <gateway-host> --port 18790`
|
- `node start --host <gateway-host> --port 18790`
|
||||||
- `node service status`
|
- `node daemon status`
|
||||||
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
- `node daemon install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
||||||
- `node service uninstall`
|
- `node daemon uninstall`
|
||||||
- `node service start`
|
- `node daemon start`
|
||||||
- `node service stop`
|
- `node daemon stop`
|
||||||
- `node service restart`
|
- `node daemon restart`
|
||||||
|
|
||||||
Legacy alias:
|
|
||||||
- `node daemon …` (same as `node service …`)
|
|
||||||
|
|
||||||
## Nodes
|
## Nodes
|
||||||
|
|
||||||
|
|||||||
+5
-14
@@ -7,8 +7,8 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot memory`
|
# `clawdbot memory`
|
||||||
|
|
||||||
Manage semantic memory indexing and search.
|
Memory search tools (semantic memory status/index/search).
|
||||||
Provided by the active memory plugin (default: `memory-core`; set `plugins.slots.memory = "none"` to disable).
|
Provided by the active memory plugin (default: `memory-core`; use `plugins.slots.memory = "none"` to disable).
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Memory concept: [Memory](/concepts/memory)
|
- Memory concept: [Memory](/concepts/memory)
|
||||||
@@ -22,20 +22,11 @@ clawdbot memory status --deep
|
|||||||
clawdbot memory status --deep --index
|
clawdbot memory status --deep --index
|
||||||
clawdbot memory status --deep --index --verbose
|
clawdbot memory status --deep --index --verbose
|
||||||
clawdbot memory index
|
clawdbot memory index
|
||||||
clawdbot memory index --verbose
|
|
||||||
clawdbot memory search "release checklist"
|
clawdbot memory search "release checklist"
|
||||||
clawdbot memory status --agent main
|
|
||||||
clawdbot memory index --agent main --verbose
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
Common:
|
- `--verbose`: emit debug logs during memory probes and indexing.
|
||||||
|
- `--index-mode auto|batch|direct`: override batch usage when indexing (`direct` favors speed; `batch` favors OpenAI Batch pricing).
|
||||||
- `--agent <id>`: scope to a single agent (default: all configured agents).
|
- `--progress auto|line|log|none`: progress output mode (`log` prints updates even without a TTY).
|
||||||
- `--verbose`: emit detailed logs during probes and indexing.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `memory status --deep` probes vector + embedding availability.
|
|
||||||
- `memory status --deep --index` runs a reindex if the store is dirty.
|
|
||||||
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
|
|
||||||
+6
-17
@@ -37,14 +37,12 @@ Options:
|
|||||||
- `--node-id <id>`: Override node id (clears pairing token)
|
- `--node-id <id>`: Override node id (clears pairing token)
|
||||||
- `--display-name <name>`: Override the node display name
|
- `--display-name <name>`: Override the node display name
|
||||||
|
|
||||||
## Service (background)
|
## Daemon (background service)
|
||||||
|
|
||||||
Install a headless node host as a user service.
|
Install a headless node host as a user service.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot node service install --host <gateway-host> --port 18790
|
clawdbot node daemon install --host <gateway-host> --port 18790
|
||||||
# or
|
|
||||||
clawdbot service node install --host <gateway-host> --port 18790
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -59,20 +57,12 @@ Options:
|
|||||||
|
|
||||||
Manage the service:
|
Manage the service:
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot node status
|
|
||||||
clawdbot service node status
|
|
||||||
clawdbot node service status
|
|
||||||
clawdbot node service start
|
|
||||||
clawdbot node service stop
|
|
||||||
clawdbot node service restart
|
|
||||||
clawdbot node service uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
Legacy alias:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot node daemon status
|
clawdbot node daemon status
|
||||||
|
clawdbot node daemon start
|
||||||
|
clawdbot node daemon stop
|
||||||
|
clawdbot node daemon restart
|
||||||
|
clawdbot node daemon uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pairing
|
## Pairing
|
||||||
@@ -93,4 +83,3 @@ The node host stores its node id + token in `~/.clawdbot/node.json`.
|
|||||||
|
|
||||||
- `~/.clawdbot/exec-approvals.json`
|
- `~/.clawdbot/exec-approvals.json`
|
||||||
- [Exec approvals](/tools/exec-approvals)
|
- [Exec approvals](/tools/exec-approvals)
|
||||||
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
|
|
||||||
read_when:
|
|
||||||
- You want to manage Gateway or node services cross-platform
|
|
||||||
- You want a single surface for start/stop/install/uninstall
|
|
||||||
---
|
|
||||||
|
|
||||||
# `clawdbot service`
|
|
||||||
|
|
||||||
Manage the **Gateway** service and **node host** services.
|
|
||||||
|
|
||||||
Related:
|
|
||||||
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
|
|
||||||
- Node host: [Node](/cli/node)
|
|
||||||
|
|
||||||
## Gateway service
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot service gateway status
|
|
||||||
clawdbot service gateway install --port 18789
|
|
||||||
clawdbot service gateway start
|
|
||||||
clawdbot service gateway stop
|
|
||||||
clawdbot service gateway restart
|
|
||||||
clawdbot service gateway uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `service gateway status` supports `--json` and `--deep` for system checks.
|
|
||||||
- `service gateway install` supports `--runtime node|bun` and `--token`.
|
|
||||||
|
|
||||||
## Node host service
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot service node status
|
|
||||||
clawdbot service node install --host <gateway-host> --port 18790
|
|
||||||
clawdbot service node start
|
|
||||||
clawdbot service node stop
|
|
||||||
clawdbot service node restart
|
|
||||||
clawdbot service node uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
|
|
||||||
and TLS options (`--tls`, `--tls-fingerprint`).
|
|
||||||
|
|
||||||
## Aliases
|
|
||||||
|
|
||||||
- `clawdbot daemon …` → `clawdbot service gateway …`
|
|
||||||
- `clawdbot node service …` → `clawdbot service node …`
|
|
||||||
- `clawdbot node status` → `clawdbot service node status`
|
|
||||||
- `clawdbot node daemon …` → `clawdbot service node …` (legacy)
|
|
||||||
@@ -19,5 +19,4 @@ clawdbot status --usage
|
|||||||
Notes:
|
Notes:
|
||||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||||
- Output includes per-agent session stores when multiple agents are configured.
|
- Output includes per-agent session stores when multiple agents are configured.
|
||||||
- Overview includes Gateway + Node service install/runtime status when available.
|
|
||||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||||
@@ -5,19 +5,13 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Agent Loop (Clawdbot)
|
# Agent Loop (Clawdbot)
|
||||||
|
|
||||||
An agentic loop is the full “real” run of an agent: intake → context assembly → model inference →
|
Short, exact flow of one agent run.
|
||||||
tool execution → streaming replies → persistence. It’s the authoritative path that turns a message
|
|
||||||
into actions and a final reply, while keeping session state consistent.
|
|
||||||
|
|
||||||
In Clawdbot, a loop is a single, serialized run per session that emits lifecycle and stream events
|
|
||||||
as the model thinks, calls tools, and streams output. This doc explains how that authentic loop is
|
|
||||||
wired end-to-end.
|
|
||||||
|
|
||||||
## Entry points
|
## Entry points
|
||||||
- Gateway RPC: `agent` and `agent.wait`.
|
- Gateway RPC: `agent` and `agent.wait`.
|
||||||
- CLI: `agent` command.
|
- CLI: `agent` command.
|
||||||
|
|
||||||
## How it works (high-level)
|
## High-level flow
|
||||||
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
||||||
2) `agentCommand` runs the agent:
|
2) `agentCommand` runs the agent:
|
||||||
- resolves model + thinking/verbose defaults
|
- resolves model + thinking/verbose defaults
|
||||||
@@ -25,9 +19,8 @@ wired end-to-end.
|
|||||||
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
||||||
- emits **lifecycle end/error** if the embedded loop does not emit one
|
- emits **lifecycle end/error** if the embedded loop does not emit one
|
||||||
3) `runEmbeddedPiAgent`:
|
3) `runEmbeddedPiAgent`:
|
||||||
- serializes runs via per-session + global queues
|
- builds `AgentSession` and subscribes to pi events
|
||||||
- resolves model + auth profile and builds the pi session
|
- streams assistant deltas + tool events
|
||||||
- subscribes to pi events and streams assistant/tool deltas
|
|
||||||
- enforces timeout -> aborts run if exceeded
|
- enforces timeout -> aborts run if exceeded
|
||||||
- returns payloads + usage metadata
|
- returns payloads + usage metadata
|
||||||
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
|
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
|
||||||
@@ -38,73 +31,6 @@ wired end-to-end.
|
|||||||
- waits for **lifecycle end/error** for `runId`
|
- waits for **lifecycle end/error** for `runId`
|
||||||
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
||||||
|
|
||||||
## Queueing + concurrency
|
|
||||||
- Runs are serialized per session key (session lane) and optionally through a global lane.
|
|
||||||
- This prevents tool/session races and keeps session history consistent.
|
|
||||||
- Messaging channels can choose queue modes (collect/steer/followup) that feed this lane system.
|
|
||||||
See [Command Queue](/concepts/queue).
|
|
||||||
|
|
||||||
## Session + workspace preparation
|
|
||||||
- Workspace is resolved and created; sandboxed runs may redirect to a sandbox workspace root.
|
|
||||||
- Skills are loaded (or reused from a snapshot) and injected into env and prompt.
|
|
||||||
- Bootstrap/context files are resolved and injected into the system prompt report.
|
|
||||||
- A session write lock is acquired; `SessionManager` is opened and prepared before streaming.
|
|
||||||
|
|
||||||
## Prompt assembly + system prompt
|
|
||||||
- System prompt is built from Clawdbot’s base prompt, skills prompt, bootstrap context, and per-run overrides.
|
|
||||||
- Model-specific limits and compaction reserve tokens are enforced.
|
|
||||||
- See [System prompt](/concepts/system-prompt) for what the model sees.
|
|
||||||
|
|
||||||
## Hook points (where you can intercept)
|
|
||||||
Clawdbot has two hook systems:
|
|
||||||
- **Internal hooks** (Gateway hooks): event-driven scripts for commands and lifecycle events.
|
|
||||||
- **Plugin hooks**: extension points inside the agent/tool lifecycle and gateway pipeline.
|
|
||||||
|
|
||||||
### Internal hooks (Gateway hooks)
|
|
||||||
- **`agent:bootstrap`**: runs while building bootstrap files before the system prompt is finalized.
|
|
||||||
Use this to add/remove bootstrap context files.
|
|
||||||
- **Command hooks**: `/new`, `/reset`, `/stop`, and other command events (see Hooks doc).
|
|
||||||
|
|
||||||
See [Hooks](/hooks) for setup and examples.
|
|
||||||
|
|
||||||
### Plugin hooks (agent + gateway lifecycle)
|
|
||||||
These run inside the agent loop or gateway pipeline:
|
|
||||||
- **`before_agent_start`**: inject context or override system prompt before the run starts.
|
|
||||||
- **`agent_end`**: inspect the final message list and run metadata after completion.
|
|
||||||
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
|
|
||||||
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
|
|
||||||
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
|
|
||||||
- **`session_start` / `session_end`**: session lifecycle boundaries.
|
|
||||||
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
|
|
||||||
|
|
||||||
See [Plugins](/plugin#plugin-hooks) for the hook API and registration details.
|
|
||||||
|
|
||||||
## Streaming + partial replies
|
|
||||||
- Assistant deltas are streamed from pi-agent-core and emitted as `assistant` events.
|
|
||||||
- Block streaming can emit partial replies either on `text_end` or `message_end`.
|
|
||||||
- Reasoning streaming can be emitted as a separate stream or as block replies.
|
|
||||||
- See [Streaming](/concepts/streaming) for chunking and block reply behavior.
|
|
||||||
|
|
||||||
## Tool execution + messaging tools
|
|
||||||
- Tool start/update/end events are emitted on the `tool` stream.
|
|
||||||
- Tool results are sanitized for size and image payloads before logging/emitting.
|
|
||||||
- Messaging tool sends are tracked to suppress duplicate assistant confirmations.
|
|
||||||
|
|
||||||
## Reply shaping + suppression
|
|
||||||
- Final payloads are assembled from:
|
|
||||||
- assistant text (and optional reasoning)
|
|
||||||
- inline tool summaries (when verbose + allowed)
|
|
||||||
- assistant error text when the model errors
|
|
||||||
- `NO_REPLY` is treated as a silent token and filtered from outgoing payloads.
|
|
||||||
- Messaging tool duplicates are removed from the final payload list.
|
|
||||||
- If no renderable payloads remain and a tool errored, a fallback tool error reply is emitted
|
|
||||||
(unless a messaging tool already sent a user-visible reply).
|
|
||||||
|
|
||||||
## Compaction + retries
|
|
||||||
- Auto-compaction emits `compaction` stream events and can trigger a retry.
|
|
||||||
- On retry, in-memory buffers and tool summaries are reset to avoid duplicate output.
|
|
||||||
- See [Compaction](/concepts/compaction) for the compaction pipeline.
|
|
||||||
|
|
||||||
## Event streams (today)
|
## Event streams (today)
|
||||||
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
||||||
- `assistant`: streamed deltas from pi-agent-core
|
- `assistant`: streamed deltas from pi-agent-core
|
||||||
|
|||||||
@@ -86,10 +86,6 @@ These are the standard files Clawdbot expects inside the workspace:
|
|||||||
- Optional tiny checklist for heartbeat runs.
|
- Optional tiny checklist for heartbeat runs.
|
||||||
- Keep it short to avoid token burn.
|
- Keep it short to avoid token burn.
|
||||||
|
|
||||||
- `BOOT.md`
|
|
||||||
- Optional startup checklist executed on gateway restart when internal hooks are enabled.
|
|
||||||
- Keep it short; use the message tool for outbound sends.
|
|
||||||
|
|
||||||
- `BOOTSTRAP.md`
|
- `BOOTSTRAP.md`
|
||||||
- One-time first-run ritual.
|
- One-time first-run ritual.
|
||||||
- Only created for a brand-new workspace.
|
- Only created for a brand-new workspace.
|
||||||
|
|||||||
+18
-32
@@ -79,46 +79,37 @@ semantic queries can find related notes even when wording differs.
|
|||||||
Defaults:
|
Defaults:
|
||||||
- Enabled by default.
|
- Enabled by default.
|
||||||
- Watches memory files for changes (debounced).
|
- Watches memory files for changes (debounced).
|
||||||
- Uses remote embeddings by default. If `memorySearch.provider` is not set, Clawdbot auto-selects:
|
- Uses remote embeddings (OpenAI) unless configured for local.
|
||||||
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
|
|
||||||
2. `openai` if an OpenAI key can be resolved.
|
|
||||||
3. `gemini` if a Gemini key can be resolved.
|
|
||||||
4. Otherwise memory search stays disabled until configured.
|
|
||||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||||
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
||||||
|
|
||||||
Remote embeddings **require** an API key for the embedding provider. Clawdbot
|
Remote embeddings **require** an API key for the embedding provider. By default
|
||||||
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
|
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
||||||
variables. Codex OAuth only covers chat/completions and does **not** satisfy
|
OAuth only covers chat/completions and does **not** satisfy embeddings for
|
||||||
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
|
memory search. When using a custom OpenAI-compatible endpoint, set
|
||||||
`models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint,
|
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||||
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
|
||||||
|
|
||||||
### Gemini embeddings (native)
|
If you want to use **Gemini embeddings** directly, set the provider to `gemini`:
|
||||||
|
|
||||||
Set the provider to `gemini` to use the Gemini embeddings API directly:
|
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
memorySearch: {
|
memorySearch: {
|
||||||
provider: "gemini",
|
provider: "gemini",
|
||||||
model: "gemini-embedding-001",
|
model: "gemini-embedding-001", // default
|
||||||
remote: {
|
remote: {
|
||||||
apiKey: "YOUR_GEMINI_API_KEY"
|
apiKey: "${GEMINI_API_KEY}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Gemini uses `GEMINI_API_KEY` (or `models.providers.google.apiKey`). Override
|
||||||
- `remote.baseUrl` is optional (defaults to the Gemini API base URL).
|
`memorySearch.remote.baseUrl` to point at a custom Gemini-compatible endpoint.
|
||||||
- `remote.headers` lets you add extra headers if needed.
|
|
||||||
- Default model: `gemini-embedding-001`.
|
|
||||||
|
|
||||||
If you want to use a **custom OpenAI-compatible endpoint** (OpenRouter, vLLM, or a proxy),
|
If you want to use a **custom OpenAI-compatible endpoint** (like OpenRouter or a proxy),
|
||||||
you can use the `remote` configuration with the OpenAI provider:
|
you can use the `remote` configuration:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
agents: {
|
agents: {
|
||||||
@@ -127,8 +118,8 @@ agents: {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "text-embedding-3-small",
|
model: "text-embedding-3-small",
|
||||||
remote: {
|
remote: {
|
||||||
baseUrl: "https://api.example.com/v1/",
|
baseUrl: "https://proxy.example/v1",
|
||||||
apiKey: "YOUR_OPENAI_COMPAT_API_KEY",
|
apiKey: "YOUR_PROXY_KEY",
|
||||||
headers: { "X-Custom-Header": "value" }
|
headers: { "X-Custom-Header": "value" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,16 +130,11 @@ agents: {
|
|||||||
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||||
`memorySearch.fallback = "none"`.
|
`memorySearch.fallback = "none"`.
|
||||||
|
|
||||||
Fallbacks:
|
Batch indexing (OpenAI only):
|
||||||
- `memorySearch.fallback` can be `openai`, `gemini`, `local`, or `none`.
|
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
||||||
- The fallback provider is only used when the primary embedding provider fails.
|
|
||||||
|
|
||||||
Batch indexing (OpenAI + Gemini):
|
|
||||||
- Enabled by default for OpenAI and Gemini embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
|
||||||
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
||||||
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
|
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
|
||||||
- Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key.
|
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
|
||||||
- Gemini batch jobs use the async embeddings batch endpoint and require Gemini Batch API availability.
|
|
||||||
|
|
||||||
Why OpenAI batch is fast + cheap:
|
Why OpenAI batch is fast + cheap:
|
||||||
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
|
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ The prompt is intentionally compact and uses fixed sections:
|
|||||||
- **Skills** (when available): tells the model how to load skill instructions on demand.
|
- **Skills** (when available): tells the model how to load skill instructions on demand.
|
||||||
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
||||||
- **Workspace**: working directory (`agents.defaults.workspace`).
|
- **Workspace**: working directory (`agents.defaults.workspace`).
|
||||||
- **Documentation**: local path to Clawdbot docs (repo or npm package) and when to read them.
|
|
||||||
- **Workspace Files (injected)**: indicates bootstrap files are included below.
|
- **Workspace Files (injected)**: indicates bootstrap files are included below.
|
||||||
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
||||||
- **Current Date & Time**: user-local time, timezone, and time format.
|
- **Current Date & Time**: user-local time, timezone, and time format.
|
||||||
@@ -99,12 +98,3 @@ Skills section is omitted.
|
|||||||
```
|
```
|
||||||
|
|
||||||
This keeps the base prompt small while still enabling targeted skill usage.
|
This keeps the base prompt small while still enabling targeted skill usage.
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
When available, the system prompt includes a **Documentation** section that points to the
|
|
||||||
local Clawdbot docs directory (either `docs/` in the repo workspace or the bundled npm
|
|
||||||
package docs) and also notes the public mirror, source repo, community Discord, and
|
|
||||||
ClawdHub (https://clawdhub.com) for skills discovery. The prompt instructs the model to consult local docs first
|
|
||||||
for Clawdbot behavior, commands, configuration, or architecture, and to run
|
|
||||||
`clawdbot status` itself when possible (asking the user only when it lacks access).
|
|
||||||
@@ -657,10 +657,6 @@
|
|||||||
"source": "/templates/AGENTS",
|
"source": "/templates/AGENTS",
|
||||||
"destination": "/reference/templates/AGENTS"
|
"destination": "/reference/templates/AGENTS"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"source": "/templates/BOOT",
|
|
||||||
"destination": "/reference/templates/BOOT"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"source": "/templates/BOOTSTRAP",
|
"source": "/templates/BOOTSTRAP",
|
||||||
"destination": "/reference/templates/BOOTSTRAP"
|
"destination": "/reference/templates/BOOTSTRAP"
|
||||||
@@ -826,10 +822,8 @@
|
|||||||
"cli/models",
|
"cli/models",
|
||||||
"cli/logs",
|
"cli/logs",
|
||||||
"cli/nodes",
|
"cli/nodes",
|
||||||
"cli/approvals",
|
|
||||||
"cli/gateway",
|
"cli/gateway",
|
||||||
"cli/daemon",
|
"cli/daemon",
|
||||||
"cli/service",
|
|
||||||
"cli/tui",
|
"cli/tui",
|
||||||
"cli/voicecall",
|
"cli/voicecall",
|
||||||
"cli/wake",
|
"cli/wake",
|
||||||
@@ -1057,7 +1051,6 @@
|
|||||||
"reference/RELEASING",
|
"reference/RELEASING",
|
||||||
"reference/AGENTS.default",
|
"reference/AGENTS.default",
|
||||||
"reference/templates/AGENTS",
|
"reference/templates/AGENTS",
|
||||||
"reference/templates/BOOT",
|
|
||||||
"reference/templates/BOOTSTRAP",
|
"reference/templates/BOOTSTRAP",
|
||||||
"reference/templates/HEARTBEAT",
|
"reference/templates/HEARTBEAT",
|
||||||
"reference/templates/IDENTITY",
|
"reference/templates/IDENTITY",
|
||||||
|
|||||||
+2
-30
@@ -37,11 +37,10 @@ The hooks system allows you to:
|
|||||||
|
|
||||||
### Bundled Hooks
|
### Bundled Hooks
|
||||||
|
|
||||||
Clawdbot ships with four bundled hooks that are automatically discovered:
|
Clawdbot ships with three bundled hooks that are automatically discovered:
|
||||||
|
|
||||||
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
||||||
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
||||||
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
|
|
||||||
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
|
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
|
||||||
|
|
||||||
List available hooks:
|
List available hooks:
|
||||||
@@ -196,7 +195,7 @@ Each event includes:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
type: 'command' | 'session' | 'agent' | 'gateway',
|
type: 'command' | 'session' | 'agent',
|
||||||
action: string, // e.g., 'new', 'reset', 'stop'
|
action: string, // e.g., 'new', 'reset', 'stop'
|
||||||
sessionKey: string, // Session identifier
|
sessionKey: string, // Session identifier
|
||||||
timestamp: Date, // When the event occurred
|
timestamp: Date, // When the event occurred
|
||||||
@@ -229,12 +228,6 @@ Triggered when agent commands are issued:
|
|||||||
|
|
||||||
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
|
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
|
||||||
|
|
||||||
### Gateway Events
|
|
||||||
|
|
||||||
Triggered when the gateway starts:
|
|
||||||
|
|
||||||
- **`gateway:startup`**: After channels start and hooks are loaded
|
|
||||||
|
|
||||||
### Future Events
|
### Future Events
|
||||||
|
|
||||||
Planned event types:
|
Planned event types:
|
||||||
@@ -549,26 +542,6 @@ clawdbot hooks enable soul-evil
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### boot-md
|
|
||||||
|
|
||||||
Runs `BOOT.md` when the gateway starts (after channels start).
|
|
||||||
Internal hooks must be enabled for this to run.
|
|
||||||
|
|
||||||
**Events**: `gateway:startup`
|
|
||||||
|
|
||||||
**Requirements**: `workspace.dir` must be configured
|
|
||||||
|
|
||||||
**What it does**:
|
|
||||||
1. Reads `BOOT.md` from your workspace
|
|
||||||
2. Runs the instructions via the agent runner
|
|
||||||
3. Sends any requested outbound messages via the message tool
|
|
||||||
|
|
||||||
**Enable**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks enable boot-md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### Keep Handlers Fast
|
### Keep Handlers Fast
|
||||||
@@ -641,7 +614,6 @@ The gateway logs hook loading at startup:
|
|||||||
```
|
```
|
||||||
Registered hook: session-memory -> command:new
|
Registered hook: session-memory -> command:new
|
||||||
Registered hook: command-logger -> command
|
Registered hook: command-logger -> command
|
||||||
Registered hook: boot-md -> gateway:startup
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Discovery
|
### Check Discovery
|
||||||
|
|||||||
+2
-2
@@ -149,8 +149,8 @@ Notes:
|
|||||||
|
|
||||||
## System commands (node host / mac node)
|
## System commands (node host / mac node)
|
||||||
|
|
||||||
The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`.
|
The macOS node exposes `system.run` and `system.notify`. The headless node host
|
||||||
The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`.
|
exposes `system.run` and `system.which`.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
|
|||||||
@@ -104,29 +104,6 @@ Rules:
|
|||||||
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
||||||
**active reply model** when its provider supports the capability.
|
**active reply model** when its provider supports the capability.
|
||||||
|
|
||||||
### Auto-enable audio (when keys exist)
|
|
||||||
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
|
|
||||||
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
|
|
||||||
even when you haven’t listed models explicitly.
|
|
||||||
|
|
||||||
Providers checked (in order):
|
|
||||||
1) OpenAI
|
|
||||||
2) Groq
|
|
||||||
3) Deepgram
|
|
||||||
|
|
||||||
To disable this behavior, set:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
tools: {
|
|
||||||
media: {
|
|
||||||
audio: {
|
|
||||||
enabled: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Capabilities (optional)
|
## Capabilities (optional)
|
||||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||||
lists, Clawdbot can infer defaults:
|
lists, Clawdbot can infer defaults:
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ read_when:
|
|||||||
# Gateway on macOS (external launchd)
|
# Gateway on macOS (external launchd)
|
||||||
|
|
||||||
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
|
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
|
||||||
expects an **external** `clawdbot` CLI install, does not spawn the Gateway as a
|
expects an **external** `clawdbot` CLI install and manages a per‑user launchd
|
||||||
child process, and manages a per‑user launchd service to keep the Gateway
|
service to keep the Gateway running.
|
||||||
running (or attaches to an existing local Gateway if one is already running).
|
|
||||||
|
|
||||||
## Install the CLI (required for local mode)
|
## Install the CLI (required for local mode)
|
||||||
|
|
||||||
@@ -39,8 +38,6 @@ Manager:
|
|||||||
Behavior:
|
Behavior:
|
||||||
- “Clawdbot Active” enables/disables the LaunchAgent.
|
- “Clawdbot Active” enables/disables the LaunchAgent.
|
||||||
- App quit does **not** stop the gateway (launchd keeps it alive).
|
- App quit does **not** stop the gateway (launchd keeps it alive).
|
||||||
- If a Gateway is already running on the configured port, the app attaches to
|
|
||||||
it instead of starting a new one.
|
|
||||||
|
|
||||||
Logging:
|
Logging:
|
||||||
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Gateway lifecycle on macOS
|
# Gateway lifecycle on macOS
|
||||||
|
|
||||||
The macOS app **manages the Gateway via launchd** by default and does not spawn
|
The macOS app **manages the Gateway via launchd** by default. The launchd job
|
||||||
the Gateway as a child process. It first tries to attach to an already‑running
|
uses the external `clawdbot` CLI (no embedded runtime). This gives you reliable
|
||||||
Gateway on the configured port; if none is reachable, it enables the launchd
|
auto‑start at login and restart on crashes.
|
||||||
service via the external `clawdbot` CLI (no embedded runtime). This gives you
|
|
||||||
reliable auto‑start at login and restart on crashes.
|
|
||||||
|
|
||||||
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
|
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
|
||||||
If you need tighter coupling to the UI, run the Gateway manually in a terminal.
|
If you need tighter coupling to the UI, run the Gateway manually in a terminal.
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
|||||||
|
|
||||||
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
|
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
|
||||||
|
|
||||||
For dev run modes, signing flags, and Team ID troubleshooting, see the macOS app README:
|
|
||||||
https://github.com/clawdbot/clawdbot/blob/main/apps/macos/README.md
|
|
||||||
|
|
||||||
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
||||||
|
|
||||||
## 3. Install the CLI
|
## 3. Install the CLI
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
|
|||||||
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
||||||
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
|
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
|
||||||
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
||||||
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -24,7 +23,6 @@ scripts/package-mac-app.sh # auto-selects identity; errors if none
|
|||||||
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
|
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
|
||||||
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
|
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
|
||||||
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
|
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
|
||||||
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh # dev-only Sparkle Team ID mismatch workaround
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ad-hoc Signing Note
|
### Ad-hoc Signing Note
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ read_when:
|
|||||||
|
|
||||||
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
|
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
|
||||||
|
|
||||||
**Planned model:** add a local Unix socket between the **node service** and the **macOS app**. The app owns `system.run` (UI/TCC context); the node service forwards exec requests over IPC.
|
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
||||||
- A small surface for automation: Gateway + node commands, plus PeekabooBridge for UI automation.
|
- A small surface for automation: Gateway + node commands, plus PeekabooBridge for UI automation.
|
||||||
@@ -19,19 +17,6 @@ read_when:
|
|||||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||||
|
|
||||||
### Node service + app IPC (planned)
|
|
||||||
- A headless node service connects to the Gateway bridge.
|
|
||||||
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
|
|
||||||
- The app performs the exec in UI context, prompts if needed, and returns output.
|
|
||||||
|
|
||||||
Diagram (SCI):
|
|
||||||
```
|
|
||||||
Agent -> Gateway -> Bridge -> Node Service (TS)
|
|
||||||
| IPC (UDS + token + HMAC + TTL)
|
|
||||||
v
|
|
||||||
Mac App (UI + TCC + system.run)
|
|
||||||
```
|
|
||||||
|
|
||||||
### PeekabooBridge (UI automation)
|
### PeekabooBridge (UI automation)
|
||||||
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
|
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
|
||||||
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
|
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
|
||||||
@@ -40,7 +25,6 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
|
|||||||
|
|
||||||
### Mach/XPC
|
### Mach/XPC
|
||||||
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
|
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
|
||||||
- Planned IPC keeps Unix sockets (no XPC helper).
|
|
||||||
|
|
||||||
## Operational flows
|
## Operational flows
|
||||||
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
|
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
|
||||||
@@ -54,4 +38,3 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
|
|||||||
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
|
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
|
||||||
- All communication remains local-only; no network sockets are exposed.
|
- All communication remains local-only; no network sockets are exposed.
|
||||||
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
|
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
|
||||||
- Planned IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
|
|
||||||
+5
-21
@@ -7,8 +7,8 @@ read_when:
|
|||||||
# Clawdbot macOS Companion (menu bar + gateway broker)
|
# Clawdbot macOS Companion (menu bar + gateway broker)
|
||||||
|
|
||||||
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
|
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
|
||||||
manages/attaches to the Gateway locally (launchd or manual), and exposes macOS
|
manages the Gateway locally, and exposes macOS capabilities to the agent as a
|
||||||
capabilities to the agent as a node.
|
node.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
@@ -20,17 +20,13 @@ capabilities to the agent as a node.
|
|||||||
- Optionally hosts **PeekabooBridge** for UI automation.
|
- Optionally hosts **PeekabooBridge** for UI automation.
|
||||||
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
|
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
|
||||||
|
|
||||||
Planned:
|
|
||||||
- Run a headless **node service** locally (launchd).
|
|
||||||
- Keep `system.run` in the app (UI/TCC context), with the node service forwarding via IPC.
|
|
||||||
|
|
||||||
## Local vs remote mode
|
## Local vs remote mode
|
||||||
|
|
||||||
- **Local** (default): the app attaches to a running local Gateway if present;
|
- **Local** (default): the app ensures a local Gateway is running via launchd.
|
||||||
otherwise it enables the launchd service via `clawdbot daemon`.
|
|
||||||
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
||||||
a local process.
|
a local process.
|
||||||
The app does not spawn the Gateway as a child process.
|
- **Attach‑only** (debug): the app connects to an already‑running local Gateway
|
||||||
|
and never spawns its own.
|
||||||
|
|
||||||
## Launchd control
|
## Launchd control
|
||||||
|
|
||||||
@@ -58,18 +54,6 @@ The macOS app presents itself as a node. Common commands:
|
|||||||
|
|
||||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||||
|
|
||||||
Planned split:
|
|
||||||
- Node service advertises the node surface to the Gateway.
|
|
||||||
- macOS app performs `system.run` in UI context over IPC.
|
|
||||||
|
|
||||||
Diagram (SCI):
|
|
||||||
```
|
|
||||||
Gateway -> Bridge -> Node Service (TS)
|
|
||||||
| IPC (UDS + token + HMAC + TTL)
|
|
||||||
v
|
|
||||||
Mac App (UI + TCC + system.run)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Exec approvals (system.run)
|
## Exec approvals (system.run)
|
||||||
|
|
||||||
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
||||||
|
|||||||
+1
-2
@@ -37,7 +37,6 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
|
|
||||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
||||||
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
||||||
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
|
|
||||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||||
@@ -46,7 +45,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||||
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
||||||
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
||||||
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
|
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
|
||||||
|
|
||||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||||
register:
|
register:
|
||||||
|
|||||||
@@ -6,27 +6,6 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Github Copilot
|
# Github Copilot
|
||||||
|
|
||||||
## What is GitHub Copilot?
|
|
||||||
|
|
||||||
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
|
|
||||||
models for your GitHub account and plan. Clawdbot can use Copilot as a model
|
|
||||||
provider in two different ways.
|
|
||||||
|
|
||||||
## Two ways to use Copilot in Clawdbot
|
|
||||||
|
|
||||||
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
|
||||||
|
|
||||||
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
|
||||||
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
|
||||||
because it does not require VS Code.
|
|
||||||
|
|
||||||
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
|
||||||
|
|
||||||
Use the **Copilot Proxy** VS Code extension as a local bridge. Clawdbot talks to
|
|
||||||
the proxy’s `/v1` endpoint and uses the model list you configure there. Choose
|
|
||||||
this when you already run Copilot Proxy in VS Code or need to route through it.
|
|
||||||
You must enable the plugin and keep the VS Code extension running.
|
|
||||||
|
|
||||||
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
||||||
the GitHub device flow, saves an auth profile, and updates your config to use that
|
the GitHub device flow, saves an auth profile, and updates your config to use that
|
||||||
profile.
|
profile.
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ read_when:
|
|||||||
- **Node identity:** use existing `nodeId`.
|
- **Node identity:** use existing `nodeId`.
|
||||||
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
||||||
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
||||||
- **macOS exec host:** run `system.run` inside the macOS app; node service forwards requests over local IPC.
|
|
||||||
- **No XPC helper:** stick to Unix socket + token + peer checks.
|
|
||||||
|
|
||||||
## Key concepts
|
## Key concepts
|
||||||
### Host
|
### Host
|
||||||
@@ -141,30 +139,19 @@ Notes:
|
|||||||
|
|
||||||
## UI integration (macOS app)
|
## UI integration (macOS app)
|
||||||
### IPC
|
### IPC
|
||||||
- Unix socket at `~/.clawdbot/exec-approvals.sock` (0600).
|
- Unix socket at `~/.clawdbot/exec-approvals.sock`.
|
||||||
- Token stored in `exec-approvals.json` (0600).
|
- Runner connects and sends an approval request; UI responds with a decision.
|
||||||
- Peer checks: same-UID only.
|
- Token stored in `exec-approvals.json`.
|
||||||
- Challenge/response: nonce + HMAC(token, request-hash) to prevent replay.
|
|
||||||
- Short TTL (e.g., 10s) + max payload + rate limit.
|
|
||||||
|
|
||||||
### Ask flow (macOS app exec host)
|
### Ask flow
|
||||||
1) Node service receives `system.run` from gateway.
|
1) Runner receives `system.run` from gateway.
|
||||||
2) Node service connects to the local socket and sends the prompt/exec request.
|
2) If ask required, runner connects to the socket and sends a prompt request.
|
||||||
3) App validates peer + token + HMAC + TTL, then shows dialog if needed.
|
3) UI shows dialog; returns decision.
|
||||||
4) App executes the command in UI context and returns output.
|
4) Runner enforces decision and proceeds.
|
||||||
5) Node service returns output to gateway.
|
|
||||||
|
|
||||||
If UI missing:
|
If UI missing:
|
||||||
- Apply `askFallback` (`deny|allowlist|full`).
|
- Apply `askFallback` (`deny|allowlist|full`).
|
||||||
|
|
||||||
### Diagram (SCI)
|
|
||||||
```
|
|
||||||
Agent -> Gateway -> Bridge -> Node Service (TS)
|
|
||||||
| IPC (UDS + token + HMAC + TTL)
|
|
||||||
v
|
|
||||||
Mac App (UI + TCC + system.run)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Node identity + binding
|
## Node identity + binding
|
||||||
- Use existing `nodeId` from Bridge pairing.
|
- Use existing `nodeId` from Bridge pairing.
|
||||||
- Binding model:
|
- Binding model:
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Audit what can spend money, which keys are used, and how to view usage"
|
|
||||||
read_when:
|
|
||||||
- You want to understand which features may call paid APIs
|
|
||||||
- You need to audit keys, costs, and usage visibility
|
|
||||||
- You’re explaining /status or /usage cost reporting
|
|
||||||
---
|
|
||||||
# API usage & costs
|
|
||||||
|
|
||||||
This doc lists **features that can invoke API keys** and where their costs show up. It focuses on
|
|
||||||
Clawdbot features that can generate provider usage or paid API calls.
|
|
||||||
|
|
||||||
## Where costs show up (chat + CLI)
|
|
||||||
|
|
||||||
**Per-session cost snapshot**
|
|
||||||
- `/status` shows the current session model, context usage, and last response tokens.
|
|
||||||
- If the model uses **API-key auth**, `/status` also shows **estimated cost** for the last reply.
|
|
||||||
|
|
||||||
**Per-message cost footer**
|
|
||||||
- `/usage full` appends a usage footer to every reply, including **estimated cost** (API-key only).
|
|
||||||
- `/usage tokens` shows tokens only; OAuth flows hide dollar cost.
|
|
||||||
|
|
||||||
**CLI usage windows (provider quotas)**
|
|
||||||
- `clawdbot status --usage` and `clawdbot channels list` show provider **usage windows**
|
|
||||||
(quota snapshots, not per-message costs).
|
|
||||||
|
|
||||||
See [Token use & costs](/token-use) for details and examples.
|
|
||||||
|
|
||||||
## How keys are discovered
|
|
||||||
|
|
||||||
Clawdbot can pick up credentials from:
|
|
||||||
- **Auth profiles** (per-agent, stored in `auth-profiles.json`).
|
|
||||||
- **Environment variables** (e.g. `OPENAI_API_KEY`, `BRAVE_API_KEY`, `FIRECRAWL_API_KEY`).
|
|
||||||
- **Config** (`models.providers.*.apiKey`, `tools.web.search.*`, `tools.web.fetch.firecrawl.*`,
|
|
||||||
`memorySearch.*`, `talk.apiKey`).
|
|
||||||
- **Skills** (`skills.entries.<name>.apiKey`) which may export keys to the skill process env.
|
|
||||||
|
|
||||||
## Features that can spend keys
|
|
||||||
|
|
||||||
### 1) Core model responses (chat + tools)
|
|
||||||
Every reply or tool call uses the **current model provider** (OpenAI, Anthropic, etc). This is the
|
|
||||||
primary source of usage and cost.
|
|
||||||
|
|
||||||
See [Models](/providers/models) for pricing config and [Token use & costs](/token-use) for display.
|
|
||||||
|
|
||||||
### 2) Media understanding (audio/image/video)
|
|
||||||
Inbound media can be summarized/transcribed before the reply runs. This uses model/provider APIs.
|
|
||||||
|
|
||||||
- Audio: OpenAI / Groq / Deepgram (now **auto-enabled** when keys exist).
|
|
||||||
- Image: OpenAI / Anthropic / Google.
|
|
||||||
- Video: Google.
|
|
||||||
|
|
||||||
See [Media understanding](/nodes/media-understanding).
|
|
||||||
|
|
||||||
### 3) Memory embeddings + semantic search
|
|
||||||
Semantic memory search uses **embedding APIs** when configured for remote providers:
|
|
||||||
- `memorySearch.provider = "openai"` → OpenAI embeddings
|
|
||||||
- `memorySearch.provider = "gemini"` → Gemini embeddings
|
|
||||||
- Optional fallback to OpenAI if local embeddings fail
|
|
||||||
|
|
||||||
You can keep it local with `memorySearch.provider = "local"` (no API usage).
|
|
||||||
|
|
||||||
See [Memory](/concepts/memory).
|
|
||||||
|
|
||||||
### 4) Web search tool (Brave / Perplexity via OpenRouter)
|
|
||||||
`web_search` uses API keys and may incur usage charges:
|
|
||||||
|
|
||||||
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
|
||||||
- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
|
||||||
|
|
||||||
**Brave free tier (generous):**
|
|
||||||
- **2,000 requests/month**
|
|
||||||
- **1 request/second**
|
|
||||||
- **Credit card required** for verification (no charge unless you upgrade)
|
|
||||||
|
|
||||||
See [Web tools](/tools/web).
|
|
||||||
|
|
||||||
### 5) Web fetch tool (Firecrawl)
|
|
||||||
`web_fetch` can call **Firecrawl** when an API key is present:
|
|
||||||
- `FIRECRAWL_API_KEY` or `tools.web.fetch.firecrawl.apiKey`
|
|
||||||
|
|
||||||
If Firecrawl isn’t configured, the tool falls back to direct fetch + readability (no paid API).
|
|
||||||
|
|
||||||
See [Web tools](/tools/web).
|
|
||||||
|
|
||||||
### 6) Provider usage snapshots (status/health)
|
|
||||||
Some status commands call **provider usage endpoints** to display quota windows or auth health.
|
|
||||||
These are typically low-volume calls but still hit provider APIs:
|
|
||||||
- `clawdbot status --usage`
|
|
||||||
- `clawdbot models status --json`
|
|
||||||
|
|
||||||
See [Models CLI](/cli/models).
|
|
||||||
|
|
||||||
### 7) Compaction safeguard summarization
|
|
||||||
The compaction safeguard can summarize session history using the **current model**, which
|
|
||||||
invokes provider APIs when it runs.
|
|
||||||
|
|
||||||
See [Session management + compaction](/reference/session-management-compaction).
|
|
||||||
|
|
||||||
### 8) Model scan / probe
|
|
||||||
`clawdbot models scan` can probe OpenRouter models and uses `OPENROUTER_API_KEY` when
|
|
||||||
probing is enabled.
|
|
||||||
|
|
||||||
See [Models CLI](/cli/models).
|
|
||||||
|
|
||||||
### 9) Talk (speech)
|
|
||||||
Talk mode can invoke **ElevenLabs** when configured:
|
|
||||||
- `ELEVENLABS_API_KEY` or `talk.apiKey`
|
|
||||||
|
|
||||||
See [Talk mode](/nodes/talk).
|
|
||||||
|
|
||||||
### 10) Skills (third-party APIs)
|
|
||||||
Skills can store `apiKey` in `skills.entries.<name>.apiKey`. If a skill uses that key for external
|
|
||||||
APIs, it can incur costs according to the skill’s provider.
|
|
||||||
|
|
||||||
See [Skills](/tools/skills).
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Workspace template for BOOT.md"
|
|
||||||
read_when:
|
|
||||||
- Adding a BOOT.md checklist
|
|
||||||
---
|
|
||||||
# BOOT.md
|
|
||||||
|
|
||||||
Add short, explicit instructions for what Clawdbot should do on startup (enable `hooks.internal.enabled`).
|
|
||||||
If the task sends a message, use the message tool and then reply with NO_REPLY.
|
|
||||||
+36
-22
@@ -60,6 +60,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||||
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
||||||
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
||||||
|
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
|
||||||
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
||||||
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
||||||
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
|
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
|
||||||
@@ -405,7 +406,7 @@ You have three supported patterns:
|
|||||||
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
|
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
|
||||||
|
|
||||||
**Option B - use a macOS node (no SSH).**
|
**Option B - use a macOS node (no SSH).**
|
||||||
Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist.
|
Run the Gateway on Linux, pair a macOS node (menubar app), and configure **Exec approvals** (Settings → Exec approvals) to "Ask" or "Always Allow". Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Ask", selecting "Always Allow" in the prompt adds that command to the allowlist.
|
||||||
|
|
||||||
**Option C - proxy macOS binaries over SSH (advanced).**
|
**Option C - proxy macOS binaries over SSH (advanced).**
|
||||||
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
|
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
|
||||||
@@ -501,23 +502,14 @@ is writable (read-only sandboxes skip it). See [Memory](/concepts/memory).
|
|||||||
|
|
||||||
### Does semantic memory search require an OpenAI API key?
|
### Does semantic memory search require an OpenAI API key?
|
||||||
|
|
||||||
Only if you use **OpenAI embeddings**. Codex OAuth covers chat/completions and
|
Only if you use **remote embeddings** (OpenAI). Codex OAuth covers
|
||||||
does **not** grant embeddings access, so **signing in with Codex (OAuth or the
|
chat/completions and does **not** grant embeddings access, so **signing in with
|
||||||
Codex CLI login)** does not help for semantic memory search. OpenAI embeddings
|
Codex (OAuth or the Codex CLI login)** does not help for semantic memory search.
|
||||||
still need a real API key (`OPENAI_API_KEY` or `models.providers.openai.apiKey`).
|
Remote memory search still needs a real OpenAI API key (`OPENAI_API_KEY` or
|
||||||
|
`models.providers.openai.apiKey`). If you’d rather stay local, set
|
||||||
If you don’t set a provider explicitly, Clawdbot auto-selects a provider when it
|
`memorySearch.provider = "local"` (and optionally `memorySearch.fallback =
|
||||||
can resolve an API key (auth profiles, `models.providers.*.apiKey`, or env vars).
|
"none"`). We support **remote or local embedding models** — see [Memory](/concepts/memory)
|
||||||
It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key
|
for the setup details.
|
||||||
resolves. If neither key is available, memory search stays disabled until you
|
|
||||||
configure it. If you have a local model path configured and present, Clawdbot
|
|
||||||
prefers `local`.
|
|
||||||
|
|
||||||
If you’d rather stay local, set `memorySearch.provider = "local"` (and optionally
|
|
||||||
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
|
|
||||||
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
|
|
||||||
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, or local** embedding
|
|
||||||
models — see [Memory](/concepts/memory) for the setup details.
|
|
||||||
|
|
||||||
## Where things live on disk
|
## Where things live on disk
|
||||||
|
|
||||||
@@ -751,6 +743,23 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
|
|||||||
|
|
||||||
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
||||||
|
|
||||||
|
### Can I run a headless node host without the macOS app?
|
||||||
|
|
||||||
|
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
|
||||||
|
without any UI. It has no screen/camera/notify support (use the macOS app for those).
|
||||||
|
|
||||||
|
Start it:
|
||||||
|
```bash
|
||||||
|
clawdbot node start --host <gateway-host> --port 18790
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
|
||||||
|
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
|
||||||
|
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
|
||||||
|
|
||||||
|
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
|
||||||
|
|
||||||
### Is there an API / RPC way to apply config?
|
### Is there an API / RPC way to apply config?
|
||||||
|
|
||||||
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
|
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
|
||||||
@@ -889,14 +898,19 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
|
|||||||
|
|
||||||
### Do sessions reset automatically if I never send `/new`?
|
### Do sessions reset automatically if I never send `/new`?
|
||||||
|
|
||||||
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
|
Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host.
|
||||||
message starts a fresh session id for that chat key. This does not delete
|
You can also add an idle window; when both daily and idle resets are configured,
|
||||||
transcripts — it just starts a new session.
|
whichever expires first starts a new session id on the next message. This does
|
||||||
|
not delete transcripts — it just starts a new session.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
session: {
|
session: {
|
||||||
idleMinutes: 240
|
reset: {
|
||||||
|
mode: "daily",
|
||||||
|
atHour: 4,
|
||||||
|
idleMinutes: 240
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ Exec approvals are enforced locally on the execution host:
|
|||||||
- **gateway host** → `clawdbot` process on the gateway machine
|
- **gateway host** → `clawdbot` process on the gateway machine
|
||||||
- **node host** → node runner (macOS companion app or headless node host)
|
- **node host** → node runner (macOS companion app or headless node host)
|
||||||
|
|
||||||
Planned macOS split:
|
|
||||||
- **node service** forwards `system.run` to the **macOS app** over local IPC.
|
|
||||||
- **macOS app** enforces approvals + executes the command in UI context.
|
|
||||||
|
|
||||||
## Settings and storage
|
## Settings and storage
|
||||||
|
|
||||||
Approvals live in a local JSON file on the execution host:
|
Approvals live in a local JSON file on the execution host:
|
||||||
@@ -111,12 +107,8 @@ overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy
|
|||||||
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
|
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
|
||||||
per pattern so you can keep the list tidy.
|
per pattern so you can keep the list tidy.
|
||||||
|
|
||||||
The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes
|
Note: the Control UI edits the approvals file on the **Gateway host**. For a
|
||||||
must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly.
|
||||||
If a node does not advertise exec approvals yet, edit its local
|
|
||||||
`~/.clawdbot/exec-approvals.json` directly.
|
|
||||||
|
|
||||||
CLI: `clawdbot approvals` supports gateway or node editing (see [Approvals CLI](/cli/approvals)).
|
|
||||||
|
|
||||||
## Approval flow
|
## Approval flow
|
||||||
|
|
||||||
@@ -132,19 +124,6 @@ Actions:
|
|||||||
- **Always allow** → add to allowlist + run
|
- **Always allow** → add to allowlist + run
|
||||||
- **Deny** → block
|
- **Deny** → block
|
||||||
|
|
||||||
### macOS IPC flow (planned)
|
|
||||||
```
|
|
||||||
Gateway -> Bridge -> Node Service (TS)
|
|
||||||
| IPC (UDS + token + HMAC + TTL)
|
|
||||||
v
|
|
||||||
Mac App (UI + approvals + system.run)
|
|
||||||
```
|
|
||||||
|
|
||||||
Security notes:
|
|
||||||
- Unix socket mode `0600`, token stored in `exec-approvals.json`.
|
|
||||||
- Same-UID peer check.
|
|
||||||
- Challenge/response (nonce + HMAC token + request hash) + short TTL.
|
|
||||||
|
|
||||||
## System events
|
## System events
|
||||||
|
|
||||||
Exec lifecycle is surfaced as system messages:
|
Exec lifecycle is surfaced as system messages:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
|||||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||||
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
||||||
- Nodes: list + caps (`node.list`)
|
- Nodes: list + caps (`node.list`)
|
||||||
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
|
- Exec approvals: edit allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
|
||||||
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
||||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
||||||
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ describeWithKey("memory plugin e2e", () => {
|
|||||||
// Dynamic import to avoid loading LanceDB when not testing
|
// Dynamic import to avoid loading LanceDB when not testing
|
||||||
const { default: memoryPlugin } = await import("./index.js");
|
const { default: memoryPlugin } = await import("./index.js");
|
||||||
|
|
||||||
expect(memoryPlugin.id).toBe("memory-lancedb");
|
expect(memoryPlugin.id).toBe("memory");
|
||||||
expect(memoryPlugin.name).toBe("Memory (LanceDB)");
|
expect(memoryPlugin.name).toBe("Memory (Vector)");
|
||||||
expect(memoryPlugin.kind).toBe("memory");
|
expect(memoryPlugin.kind).toBe("memory");
|
||||||
expect(memoryPlugin.configSchema).toBeDefined();
|
expect(memoryPlugin.configSchema).toBeDefined();
|
||||||
expect(memoryPlugin.register).toBeInstanceOf(Function);
|
expect(memoryPlugin.register).toBeInstanceOf(Function);
|
||||||
@@ -185,8 +185,8 @@ describeWithKey("memory plugin live tests", () => {
|
|||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
|
|
||||||
const mockApi = {
|
const mockApi = {
|
||||||
id: "memory-lancedb",
|
id: "memory",
|
||||||
name: "Memory (LanceDB)",
|
name: "Memory (Vector)",
|
||||||
source: "test",
|
source: "test",
|
||||||
config: {},
|
config: {},
|
||||||
pluginConfig: {
|
pluginConfig: {
|
||||||
|
|||||||
+13
-15
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Clawdbot Memory (LanceDB) Plugin
|
* Clawdbot Memory Plugin
|
||||||
*
|
*
|
||||||
* Long-term memory with vector search for AI conversations.
|
* Long-term memory with vector search for AI conversations.
|
||||||
* Uses LanceDB for storage and OpenAI for embeddings.
|
* Uses LanceDB for storage and OpenAI for embeddings.
|
||||||
@@ -214,9 +214,9 @@ function detectCategory(text: string): MemoryCategory {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const memoryPlugin = {
|
const memoryPlugin = {
|
||||||
id: "memory-lancedb",
|
id: "memory",
|
||||||
name: "Memory (LanceDB)",
|
name: "Memory (Vector)",
|
||||||
description: "LanceDB-backed long-term memory with auto-recall/capture",
|
description: "Long-term memory with vector search and seamless auto-recall/capture",
|
||||||
kind: "memory" as const,
|
kind: "memory" as const,
|
||||||
configSchema: memoryConfigSchema,
|
configSchema: memoryConfigSchema,
|
||||||
|
|
||||||
@@ -227,9 +227,7 @@ const memoryPlugin = {
|
|||||||
const db = new MemoryDB(resolvedDbPath, vectorDim);
|
const db = new MemoryDB(resolvedDbPath, vectorDim);
|
||||||
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
|
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
|
||||||
|
|
||||||
api.logger.info(
|
api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`);
|
||||||
`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Tools
|
// Tools
|
||||||
@@ -419,7 +417,7 @@ const memoryPlugin = {
|
|||||||
({ program }) => {
|
({ program }) => {
|
||||||
const memory = program
|
const memory = program
|
||||||
.command("ltm")
|
.command("ltm")
|
||||||
.description("LanceDB memory plugin commands");
|
.description("Long-term memory plugin commands");
|
||||||
|
|
||||||
memory
|
memory
|
||||||
.command("list")
|
.command("list")
|
||||||
@@ -479,14 +477,14 @@ const memoryPlugin = {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
api.logger.info?.(
|
api.logger.info?.(
|
||||||
`memory-lancedb: injecting ${results.length} memories into context`,
|
`memory: injecting ${results.length} memories into context`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
|
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
api.logger.warn(`memory-lancedb: recall failed: ${String(err)}`);
|
api.logger.warn(`memory: recall failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -561,10 +559,10 @@ const memoryPlugin = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stored > 0) {
|
if (stored > 0) {
|
||||||
api.logger.info(`memory-lancedb: auto-captured ${stored} memories`);
|
api.logger.info(`memory: auto-captured ${stored} memories`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
api.logger.warn(`memory-lancedb: capture failed: ${String(err)}`);
|
api.logger.warn(`memory: capture failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -574,14 +572,14 @@ const memoryPlugin = {
|
|||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
api.registerService({
|
api.registerService({
|
||||||
id: "memory-lancedb",
|
id: "memory",
|
||||||
start: () => {
|
start: () => {
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
`memory-lancedb: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
|
`memory: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
stop: () => {
|
stop: () => {
|
||||||
api.logger.info("memory-lancedb: stopped");
|
api.logger.info("memory: stopped");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-lancedb",
|
"name": "@clawdbot/memory",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lancedb/lancedb": "^0.15.0",
|
|
||||||
"@sinclair/typebox": "0.34.47",
|
"@sinclair/typebox": "0.34.47",
|
||||||
"openai": "^4.104.0"
|
"@lancedb/lancedb": "^0.15.0",
|
||||||
|
"openai": "^4.77.0"
|
||||||
},
|
},
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": [
|
"extensions": ["./index.ts"]
|
||||||
"./index.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/agents-hosting": "^1.2.2",
|
|
||||||
"@microsoft/agents-hosting-express": "^1.2.2",
|
|
||||||
"@microsoft/agents-hosting-extensions-teams": "^1.2.2",
|
|
||||||
"clawdbot": "workspace:*",
|
"clawdbot": "workspace:*",
|
||||||
|
"@microsoft/agents-hosting": "^1.1.1",
|
||||||
|
"@microsoft/agents-hosting-express": "^1.1.1",
|
||||||
|
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"proper-lockfile": "^4.1.2"
|
"proper-lockfile": "^4.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,34 +62,6 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
/** Map of call SID to stream SID for media streams */
|
/** Map of call SID to stream SID for media streams */
|
||||||
private callStreamMap = new Map<string, string>();
|
private callStreamMap = new Map<string, string>();
|
||||||
|
|
||||||
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
|
|
||||||
private readonly twimlStorage = new Map<string, 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.
|
|
||||||
*/
|
|
||||||
private deleteStoredTwiml(callId: string): void {
|
|
||||||
this.twimlStorage.delete(callId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
|
|
||||||
*
|
|
||||||
* This is used when we only have `providerCallId` (e.g. hangup).
|
|
||||||
*/
|
|
||||||
private deleteStoredTwimlForProviderCall(providerCallId: string): void {
|
|
||||||
const webhookUrl = this.callWebhookUrls.get(providerCallId);
|
|
||||||
if (!webhookUrl) return;
|
|
||||||
|
|
||||||
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
|
|
||||||
if (!callIdMatch) return;
|
|
||||||
|
|
||||||
this.deleteStoredTwiml(callIdMatch[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
||||||
if (!config.accountSid) {
|
if (!config.accountSid) {
|
||||||
throw new Error("Twilio Account SID is required");
|
throw new Error("Twilio Account SID is required");
|
||||||
@@ -256,14 +228,8 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
case "busy":
|
case "busy":
|
||||||
case "no-answer":
|
case "no-answer":
|
||||||
case "failed":
|
case "failed":
|
||||||
if (callIdOverride) {
|
|
||||||
this.deleteStoredTwiml(callIdOverride);
|
|
||||||
}
|
|
||||||
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
||||||
case "canceled":
|
case "canceled":
|
||||||
if (callIdOverride) {
|
|
||||||
this.deleteStoredTwiml(callIdOverride);
|
|
||||||
}
|
|
||||||
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@@ -288,25 +254,11 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
const params = new URLSearchParams(ctx.rawBody);
|
const params = new URLSearchParams(ctx.rawBody);
|
||||||
const callStatus = params.get("CallStatus");
|
const callStatus = params.get("CallStatus");
|
||||||
const direction = params.get("Direction");
|
const direction = params.get("Direction");
|
||||||
const callIdFromQuery =
|
|
||||||
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
|
||||||
? ctx.query.callId.trim()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Avoid logging webhook params/TwiML (may contain PII).
|
console.log(
|
||||||
|
`[voice-call] generateTwimlResponse: status=${callStatus} direction=${direction}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Handle initial TwiML request (when Twilio first initiates the call)
|
|
||||||
// Check if we have stored TwiML for this call (notify mode)
|
|
||||||
if (callIdFromQuery) {
|
|
||||||
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
|
|
||||||
if (storedTwiml) {
|
|
||||||
// Clean up after serving (one-time use)
|
|
||||||
this.deleteStoredTwiml(callIdFromQuery);
|
|
||||||
return storedTwiml;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle subsequent webhook requests (status callbacks, etc.)
|
|
||||||
// For inbound calls, answer immediately with stream
|
// For inbound calls, answer immediately with stream
|
||||||
if (direction === "inbound") {
|
if (direction === "inbound") {
|
||||||
const streamUrl = this.getStreamUrl();
|
const streamUrl = this.getStreamUrl();
|
||||||
@@ -376,28 +328,22 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
const url = new URL(input.webhookUrl);
|
const url = new URL(input.webhookUrl);
|
||||||
url.searchParams.set("callId", input.callId);
|
url.searchParams.set("callId", input.callId);
|
||||||
|
|
||||||
// Create separate URL for status callbacks (required by Twilio)
|
// Build request params
|
||||||
const statusUrl = new URL(input.webhookUrl);
|
|
||||||
statusUrl.searchParams.set("callId", input.callId);
|
|
||||||
statusUrl.searchParams.set("type", "status"); // Differentiate from TwiML requests
|
|
||||||
|
|
||||||
// Store TwiML content if provided (for notify mode)
|
|
||||||
// We now serve it from the webhook endpoint instead of sending inline
|
|
||||||
if (input.inlineTwiml) {
|
|
||||||
this.twimlStorage.set(input.callId, input.inlineTwiml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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> = {
|
||||||
To: input.to,
|
To: input.to,
|
||||||
From: input.from,
|
From: input.from,
|
||||||
Url: url.toString(), // TwiML serving endpoint
|
StatusCallback: url.toString(),
|
||||||
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
|
|
||||||
StatusCallbackEvent: "initiated ringing answered completed",
|
StatusCallbackEvent: "initiated ringing answered completed",
|
||||||
Timeout: "30",
|
Timeout: "30",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Use inline TwiML for notify mode (simpler, no webhook needed)
|
||||||
|
if (input.inlineTwiml) {
|
||||||
|
params.Twiml = input.inlineTwiml;
|
||||||
|
} else {
|
||||||
|
params.Url = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.apiRequest<TwilioCallResponse>(
|
const result = await this.apiRequest<TwilioCallResponse>(
|
||||||
"/Calls.json",
|
"/Calls.json",
|
||||||
params,
|
params,
|
||||||
@@ -415,8 +361,6 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
* Hang up a call via Twilio API.
|
* Hang up a call via Twilio API.
|
||||||
*/
|
*/
|
||||||
async hangupCall(input: HangupCallInput): Promise<void> {
|
async hangupCall(input: HangupCallInput): Promise<void> {
|
||||||
this.deleteStoredTwimlForProviderCall(input.providerCallId);
|
|
||||||
|
|
||||||
this.callWebhookUrls.delete(input.providerCallId);
|
this.callWebhookUrls.delete(input.providerCallId);
|
||||||
|
|
||||||
await this.apiRequest(
|
await this.apiRequest(
|
||||||
|
|||||||
+22
-22
@@ -65,27 +65,27 @@
|
|||||||
"dist/whatsapp/**"
|
"dist/whatsapp/**"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun src/entry.ts",
|
"dev": "tsx src/entry.ts",
|
||||||
"postinstall": "node scripts/postinstall.js",
|
"postinstall": "node scripts/postinstall.js",
|
||||||
"prepack": "pnpm build",
|
"prepack": "pnpm build",
|
||||||
"docs:list": "bun scripts/docs-list.ts",
|
"docs:list": "tsx scripts/docs-list.ts",
|
||||||
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
|
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
|
||||||
"docs:dev": "cd docs && mint dev",
|
"docs:dev": "cd docs && mint dev",
|
||||||
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
||||||
"build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts && bun scripts/copy-hook-metadata.ts && bun scripts/write-build-info.ts",
|
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts && tsx scripts/write-build-info.ts",
|
||||||
"plugins:sync": "bun scripts/sync-plugin-versions.ts",
|
"plugins:sync": "tsx scripts/sync-plugin-versions.ts",
|
||||||
"release:check": "bun scripts/release-check.ts",
|
"release:check": "tsx scripts/release-check.ts",
|
||||||
"ui:install": "node scripts/ui.js install",
|
"ui:install": "node scripts/ui.js install",
|
||||||
"ui:dev": "node scripts/ui.js dev",
|
"ui:dev": "node scripts/ui.js dev",
|
||||||
"ui:build": "node scripts/ui.js build",
|
"ui:build": "node scripts/ui.js build",
|
||||||
"start": "bun src/entry.ts",
|
"start": "tsx src/entry.ts",
|
||||||
"clawdbot": "bun src/entry.ts",
|
"clawdbot": "tsx src/entry.ts",
|
||||||
"gateway:watch": "bun --watch src/entry.ts gateway --force",
|
"gateway:watch": "tsx watch src/entry.ts gateway --force",
|
||||||
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway",
|
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 tsx src/entry.ts --dev gateway",
|
||||||
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway --reset",
|
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 tsx src/entry.ts --dev gateway --reset",
|
||||||
"tui": "bun src/entry.ts tui",
|
"tui": "tsx src/entry.ts tui",
|
||||||
"tui:dev": "CLAWDBOT_PROFILE=dev bun src/entry.ts tui",
|
"tui:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts tui",
|
||||||
"clawdbot:rpc": "bun src/entry.ts agent --mode rpc --json",
|
"clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
|
||||||
"ios:gen": "cd apps/ios && xcodegen generate",
|
"ios:gen": "cd apps/ios && xcodegen generate",
|
||||||
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
|
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
|
||||||
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
|
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:ui": "pnpm --dir ui test",
|
"test:ui": "pnpm --dir ui test",
|
||||||
"test:force": "bun scripts/test-force.ts",
|
"test:force": "tsx scripts/test-force.ts",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||||
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
|
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
|
||||||
@@ -126,8 +126,8 @@
|
|||||||
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
|
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
|
||||||
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
|
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
|
||||||
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
|
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
|
||||||
"protocol:gen": "bun scripts/protocol-gen.ts",
|
"protocol:gen": "tsx scripts/protocol-gen.ts",
|
||||||
"protocol:gen:swift": "bun scripts/protocol-gen-swift.ts",
|
"protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
|
||||||
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
|
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
|
||||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||||
"check:loc": "tsx scripts/check-ts-max-loc.ts --max 500"
|
"check:loc": "tsx scripts/check-ts-max-loc.ts --max 500"
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"file-type": "^21.3.0",
|
"file-type": "^21.3.0",
|
||||||
"grammy": "^1.39.3",
|
"grammy": "^1.39.2",
|
||||||
"hono": "4.11.4",
|
"hono": "4.11.4",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"sqlite-vec": "0.1.7-alpha.2",
|
"sqlite-vec": "0.1.7-alpha.2",
|
||||||
"tar": "7.5.3",
|
"tar": "^7.5.3",
|
||||||
"tslog": "^4.10.2",
|
"tslog": "^4.10.2",
|
||||||
"undici": "^7.18.2",
|
"undici": "^7.18.2",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
@@ -200,24 +200,24 @@
|
|||||||
"@types/body-parser": "^1.19.6",
|
"@types/body-parser": "^1.19.6",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/node": "^25.0.9",
|
"@types/node": "^25.0.6",
|
||||||
"@types/proper-lockfile": "^4.1.4",
|
"@types/proper-lockfile": "^4.1.4",
|
||||||
"@types/qrcode-terminal": "^0.12.2",
|
"@types/qrcode-terminal": "^0.12.2",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitest/coverage-v8": "^4.0.17",
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"lit": "^3.3.2",
|
"lit": "^3.3.2",
|
||||||
"lucide": "^0.562.0",
|
"lucide": "^0.562.0",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"oxfmt": "0.24.0",
|
"oxfmt": "0.24.0",
|
||||||
"oxlint": "^1.39.0",
|
"oxlint": "^1.39.0",
|
||||||
"oxlint-tsgolint": "^0.11.1",
|
"oxlint-tsgolint": "^0.11.0",
|
||||||
"quicktype-core": "^23.2.6",
|
"quicktype-core": "^23.2.6",
|
||||||
"rolldown": "1.0.0-beta.59",
|
"rolldown": "1.0.0-beta.59",
|
||||||
"signal-utils": "^0.21.1",
|
"signal-utils": "^0.21.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.17",
|
"vitest": "^4.0.16",
|
||||||
"wireit": "^0.14.12"
|
"wireit": "^0.14.12"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|||||||
Generated
+737
-700
File diff suppressed because it is too large.
Load diff
@@ -4,28 +4,11 @@ set -euo pipefail
|
|||||||
APP_BUNDLE="${1:-dist/Clawdbot.app}"
|
APP_BUNDLE="${1:-dist/Clawdbot.app}"
|
||||||
IDENTITY="${SIGN_IDENTITY:-}"
|
IDENTITY="${SIGN_IDENTITY:-}"
|
||||||
TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
|
TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
|
||||||
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
|
|
||||||
SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}"
|
|
||||||
ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX)
|
ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX)
|
||||||
ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX)
|
ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX)
|
||||||
ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX)
|
ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX)
|
||||||
ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
|
ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
|
||||||
|
|
||||||
if [[ "${APP_BUNDLE}" == "--help" || "${APP_BUNDLE}" == "-h" ]]; then
|
|
||||||
cat <<'HELP'
|
|
||||||
Usage: scripts/codesign-mac-app.sh [app-bundle]
|
|
||||||
|
|
||||||
Env:
|
|
||||||
SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"
|
|
||||||
ALLOW_ADHOC_SIGNING=1
|
|
||||||
CODESIGN_TIMESTAMP=auto|on|off
|
|
||||||
DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround
|
|
||||||
SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit
|
|
||||||
ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1
|
|
||||||
HELP
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d "$APP_BUNDLE" ]; then
|
if [ ! -d "$APP_BUNDLE" ]; then
|
||||||
echo "App bundle not found: $APP_BUNDLE" >&2
|
echo "App bundle not found: $APP_BUNDLE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -201,14 +184,6 @@ cat > "$ENT_TMP_APP" <<'PLIST'
|
|||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
|
|
||||||
if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then
|
|
||||||
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \
|
|
||||||
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE"
|
|
||||||
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP" >/dev/null 2>&1 || \
|
|
||||||
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP"
|
|
||||||
echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The time-sensitive entitlement is restricted and requires explicit enablement
|
# The time-sensitive entitlement is restricted and requires explicit enablement
|
||||||
# (and typically a matching provisioning profile). It is *not* safe to enable
|
# (and typically a matching provisioning profile). It is *not* safe to enable
|
||||||
# unconditionally for local debug packaging since AMFI will refuse to launch.
|
# unconditionally for local debug packaging since AMFI will refuse to launch.
|
||||||
@@ -234,51 +209,6 @@ sign_plain_item() {
|
|||||||
codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --sign "$IDENTITY" "$target"
|
codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --sign "$IDENTITY" "$target"
|
||||||
}
|
}
|
||||||
|
|
||||||
team_id_for() {
|
|
||||||
codesign -dv --verbose=4 "$1" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2; exit}'
|
|
||||||
}
|
|
||||||
|
|
||||||
verify_team_ids() {
|
|
||||||
if [[ "$SKIP_TEAM_ID_CHECK" == "1" ]]; then
|
|
||||||
echo "Note: skipping Team ID audit (SKIP_TEAM_ID_CHECK=1)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local expected
|
|
||||||
expected="$(team_id_for "$APP_BUNDLE" || true)"
|
|
||||||
if [[ -z "$expected" ]]; then
|
|
||||||
echo "WARN: TeamIdentifier missing on app bundle; skipping Team ID audit."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local mismatches=()
|
|
||||||
while IFS= read -r -d '' f; do
|
|
||||||
if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then
|
|
||||||
local team
|
|
||||||
team="$(team_id_for "$f" || true)"
|
|
||||||
if [[ -z "$team" ]]; then
|
|
||||||
team="not set"
|
|
||||||
fi
|
|
||||||
if [[ "$expected" == "not set" ]]; then
|
|
||||||
if [[ "$team" != "not set" ]]; then
|
|
||||||
mismatches+=("$f (TeamIdentifier=$team)")
|
|
||||||
fi
|
|
||||||
elif [[ "$team" != "$expected" ]]; then
|
|
||||||
mismatches+=("$f (TeamIdentifier=$team)")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done < <(find "$APP_BUNDLE" -type f -print0)
|
|
||||||
|
|
||||||
if [[ "${#mismatches[@]}" -gt 0 ]]; then
|
|
||||||
echo "ERROR: Team ID mismatch detected (expected: $expected)"
|
|
||||||
for entry in "${mismatches[@]}"; do
|
|
||||||
echo " - $entry"
|
|
||||||
done
|
|
||||||
echo "Hint: re-sign embedded frameworks or set DISABLE_LIBRARY_VALIDATION=1 for dev builds."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Sign main binary
|
# Sign main binary
|
||||||
if [ -f "$APP_BUNDLE/Contents/MacOS/Clawdbot" ]; then
|
if [ -f "$APP_BUNDLE/Contents/MacOS/Clawdbot" ]; then
|
||||||
echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/Clawdbot" "$APP_ENTITLEMENTS"
|
echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/Clawdbot" "$APP_ENTITLEMENTS"
|
||||||
@@ -288,11 +218,6 @@ fi
|
|||||||
SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework"
|
SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework"
|
||||||
if [ -d "$SPARKLE" ]; then
|
if [ -d "$SPARKLE" ]; then
|
||||||
echo "Signing Sparkle framework and helpers"
|
echo "Signing Sparkle framework and helpers"
|
||||||
find "$SPARKLE" -type f -print0 | while IFS= read -r -d '' f; do
|
|
||||||
if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then
|
|
||||||
sign_plain_item "$f"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
sign_plain_item "$SPARKLE/Versions/B/Sparkle"
|
sign_plain_item "$SPARKLE/Versions/B/Sparkle"
|
||||||
sign_plain_item "$SPARKLE/Versions/B/Autoupdate"
|
sign_plain_item "$SPARKLE/Versions/B/Autoupdate"
|
||||||
sign_plain_item "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater"
|
sign_plain_item "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater"
|
||||||
@@ -315,7 +240,5 @@ fi
|
|||||||
# Finally sign the bundle
|
# Finally sign the bundle
|
||||||
sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
|
sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
|
||||||
|
|
||||||
verify_team_ids
|
|
||||||
|
|
||||||
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME"
|
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME"
|
||||||
echo "Codesign complete for $APP_BUNDLE"
|
echo "Codesign complete for $APP_BUNDLE"
|
||||||
@@ -50,15 +50,15 @@ import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
|||||||
|
|
||||||
const DEFAULT_MAX_OUTPUT = clampNumber(
|
const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||||
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
||||||
200_000,
|
30_000,
|
||||||
1_000,
|
1_000,
|
||||||
200_000,
|
150_000,
|
||||||
);
|
);
|
||||||
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
|
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
|
||||||
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
|
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
|
||||||
200_000,
|
30_000,
|
||||||
1_000,
|
1_000,
|
||||||
200_000,
|
150_000,
|
||||||
);
|
);
|
||||||
const DEFAULT_PATH =
|
const DEFAULT_PATH =
|
||||||
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { shouldLogVerbose } from "../globals.js";
|
|||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import { resolveClawdbotDocsPath } from "./docs-path.js";
|
|
||||||
import { resolveSessionAgentIds } from "./agent-scope.js";
|
import { resolveSessionAgentIds } from "./agent-scope.js";
|
||||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
|
||||||
import { resolveCliBackendConfig } from "./cli-backends.js";
|
import { resolveCliBackendConfig } from "./cli-backends.js";
|
||||||
@@ -84,12 +83,6 @@ export async function runCliAgent(params: {
|
|||||||
sessionAgentId === defaultAgentId
|
sessionAgentId === defaultAgentId
|
||||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||||
: undefined;
|
: undefined;
|
||||||
const docsPath = await resolveClawdbotDocsPath({
|
|
||||||
workspaceDir,
|
|
||||||
argv1: process.argv[1],
|
|
||||||
cwd: process.cwd(),
|
|
||||||
moduleUrl: import.meta.url,
|
|
||||||
});
|
|
||||||
const systemPrompt = buildSystemPrompt({
|
const systemPrompt = buildSystemPrompt({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
@@ -97,7 +90,6 @@ export async function runCliAgent(params: {
|
|||||||
extraSystemPrompt,
|
extraSystemPrompt,
|
||||||
ownerNumbers: params.ownerNumbers,
|
ownerNumbers: params.ownerNumbers,
|
||||||
heartbeatPrompt,
|
heartbeatPrompt,
|
||||||
docsPath: docsPath ?? undefined,
|
|
||||||
tools: [],
|
tools: [],
|
||||||
contextFiles,
|
contextFiles,
|
||||||
modelDisplay,
|
modelDisplay,
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ export function buildSystemPrompt(params: {
|
|||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
ownerNumbers?: string[];
|
ownerNumbers?: string[];
|
||||||
heartbeatPrompt?: string;
|
heartbeatPrompt?: string;
|
||||||
docsPath?: string;
|
|
||||||
tools: AgentTool[];
|
tools: AgentTool[];
|
||||||
contextFiles?: EmbeddedContextFile[];
|
contextFiles?: EmbeddedContextFile[];
|
||||||
modelDisplay: string;
|
modelDisplay: string;
|
||||||
@@ -183,7 +182,6 @@ export function buildSystemPrompt(params: {
|
|||||||
ownerNumbers: params.ownerNumbers,
|
ownerNumbers: params.ownerNumbers,
|
||||||
reasoningTagHint: false,
|
reasoningTagHint: false,
|
||||||
heartbeatPrompt: params.heartbeatPrompt,
|
heartbeatPrompt: params.heartbeatPrompt,
|
||||||
docsPath: params.docsPath,
|
|
||||||
runtimeInfo: {
|
runtimeInfo: {
|
||||||
host: "clawdbot",
|
host: "clawdbot",
|
||||||
os: `${os.type()} ${os.release()}`,
|
os: `${os.type()} ${os.release()}`,
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
|
||||||
|
|
||||||
export async function resolveClawdbotDocsPath(params: {
|
|
||||||
workspaceDir?: string;
|
|
||||||
argv1?: string;
|
|
||||||
cwd?: string;
|
|
||||||
moduleUrl?: string;
|
|
||||||
}): Promise<string | null> {
|
|
||||||
const workspaceDir = params.workspaceDir?.trim();
|
|
||||||
if (workspaceDir) {
|
|
||||||
const workspaceDocs = path.join(workspaceDir, "docs");
|
|
||||||
if (fs.existsSync(workspaceDocs)) return workspaceDocs;
|
|
||||||
}
|
|
||||||
|
|
||||||
const packageRoot = await resolveClawdbotPackageRoot({
|
|
||||||
cwd: params.cwd,
|
|
||||||
argv1: params.argv1,
|
|
||||||
moduleUrl: params.moduleUrl,
|
|
||||||
});
|
|
||||||
if (!packageRoot) return null;
|
|
||||||
|
|
||||||
const packageDocs = path.join(packageRoot, "docs");
|
|
||||||
return fs.existsSync(packageDocs) ? packageDocs : null;
|
|
||||||
}
|
|
||||||
@@ -22,21 +22,6 @@ describe("memory search config", () => {
|
|||||||
expect(resolved).toBeNull();
|
expect(resolved).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults provider to auto when unspecified", () => {
|
|
||||||
const cfg = {
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
memorySearch: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
|
||||||
expect(resolved?.provider).toBe("auto");
|
|
||||||
expect(resolved?.fallback).toBe("none");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("merges defaults and overrides", () => {
|
it("merges defaults and overrides", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
agents: {
|
agents: {
|
||||||
@@ -141,7 +126,6 @@ describe("memory search config", () => {
|
|||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
memorySearch: {
|
memorySearch: {
|
||||||
provider: "openai",
|
|
||||||
remote: {
|
remote: {
|
||||||
baseUrl: "https://default.example/v1",
|
baseUrl: "https://default.example/v1",
|
||||||
apiKey: "default-key",
|
apiKey: "default-key",
|
||||||
@@ -182,7 +166,6 @@ describe("memory search config", () => {
|
|||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
memorySearch: {
|
memorySearch: {
|
||||||
provider: "openai",
|
|
||||||
sources: ["memory", "sessions"],
|
sources: ["memory", "sessions"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -206,7 +189,6 @@ describe("memory search config", () => {
|
|||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
memorySearch: {
|
memorySearch: {
|
||||||
provider: "openai",
|
|
||||||
sources: ["memory", "sessions"],
|
sources: ["memory", "sessions"],
|
||||||
experimental: { sessionMemory: true },
|
experimental: { sessionMemory: true },
|
||||||
},
|
},
|
||||||
|
|||||||
+24
-30
@@ -3,13 +3,13 @@ import path from "node:path";
|
|||||||
|
|
||||||
import type { ClawdbotConfig, MemorySearchConfig } from "../config/config.js";
|
import type { ClawdbotConfig, MemorySearchConfig } from "../config/config.js";
|
||||||
import { resolveStateDir } from "../config/paths.js";
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
import { clampInt, clampNumber, resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import { resolveAgentConfig } from "./agent-scope.js";
|
import { resolveAgentConfig } from "./agent-scope.js";
|
||||||
|
|
||||||
export type ResolvedMemorySearchConfig = {
|
export type ResolvedMemorySearchConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sources: Array<"memory" | "sessions">;
|
sources: Array<"memory" | "sessions">;
|
||||||
provider: "openai" | "local" | "gemini" | "auto";
|
provider: "openai" | "gemini" | "local";
|
||||||
remote?: {
|
remote?: {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
@@ -25,7 +25,7 @@ export type ResolvedMemorySearchConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
sessionMemory: boolean;
|
sessionMemory: boolean;
|
||||||
};
|
};
|
||||||
fallback: "openai" | "gemini" | "local" | "none";
|
fallback: "openai" | "none";
|
||||||
model: string;
|
model: string;
|
||||||
local: {
|
local: {
|
||||||
modelPath?: string;
|
modelPath?: string;
|
||||||
@@ -110,40 +110,34 @@ function mergeConfig(
|
|||||||
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
|
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
|
||||||
const sessionMemory =
|
const sessionMemory =
|
||||||
overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false;
|
overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false;
|
||||||
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
|
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
|
||||||
const defaultRemote = defaults?.remote;
|
const hasRemote = Boolean(defaults?.remote || overrides?.remote);
|
||||||
const overrideRemote = overrides?.remote;
|
const includeRemote = hasRemote || provider === "openai" || provider === "gemini";
|
||||||
const hasRemote = Boolean(defaultRemote || overrideRemote);
|
|
||||||
const includeRemote =
|
|
||||||
hasRemote || provider === "openai" || provider === "gemini" || provider === "auto";
|
|
||||||
const batch = {
|
const batch = {
|
||||||
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
|
enabled: overrides?.remote?.batch?.enabled ?? defaults?.remote?.batch?.enabled ?? true,
|
||||||
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
|
wait: overrides?.remote?.batch?.wait ?? defaults?.remote?.batch?.wait ?? true,
|
||||||
concurrency: Math.max(
|
concurrency: Math.max(
|
||||||
1,
|
1,
|
||||||
overrideRemote?.batch?.concurrency ?? defaultRemote?.batch?.concurrency ?? 2,
|
overrides?.remote?.batch?.concurrency ?? defaults?.remote?.batch?.concurrency ?? 2,
|
||||||
),
|
),
|
||||||
pollIntervalMs:
|
pollIntervalMs:
|
||||||
overrideRemote?.batch?.pollIntervalMs ?? defaultRemote?.batch?.pollIntervalMs ?? 2000,
|
overrides?.remote?.batch?.pollIntervalMs ?? defaults?.remote?.batch?.pollIntervalMs ?? 2000,
|
||||||
timeoutMinutes:
|
timeoutMinutes:
|
||||||
overrideRemote?.batch?.timeoutMinutes ?? defaultRemote?.batch?.timeoutMinutes ?? 60,
|
overrides?.remote?.batch?.timeoutMinutes ?? defaults?.remote?.batch?.timeoutMinutes ?? 60,
|
||||||
};
|
};
|
||||||
const remote = includeRemote
|
const remote = includeRemote
|
||||||
? {
|
? {
|
||||||
baseUrl: overrideRemote?.baseUrl ?? defaultRemote?.baseUrl,
|
baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
|
||||||
apiKey: overrideRemote?.apiKey ?? defaultRemote?.apiKey,
|
apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
|
||||||
headers: overrideRemote?.headers ?? defaultRemote?.headers,
|
headers: overrides?.remote?.headers ?? defaults?.remote?.headers,
|
||||||
batch,
|
batch,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const fallback = overrides?.fallback ?? defaults?.fallback ?? "none";
|
const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
|
||||||
const modelDefault =
|
const model =
|
||||||
provider === "gemini"
|
overrides?.model ??
|
||||||
? DEFAULT_GEMINI_MODEL
|
defaults?.model ??
|
||||||
: provider === "openai"
|
(provider === "gemini" ? DEFAULT_GEMINI_MODEL : DEFAULT_OPENAI_MODEL);
|
||||||
? DEFAULT_OPENAI_MODEL
|
|
||||||
: undefined;
|
|
||||||
const model = overrides?.model ?? defaults?.model ?? modelDefault ?? "";
|
|
||||||
const local = {
|
const local = {
|
||||||
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
|
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
|
||||||
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
||||||
@@ -200,14 +194,14 @@ function mergeConfig(
|
|||||||
maxEntries: overrides?.cache?.maxEntries ?? defaults?.cache?.maxEntries,
|
maxEntries: overrides?.cache?.maxEntries ?? defaults?.cache?.maxEntries,
|
||||||
};
|
};
|
||||||
|
|
||||||
const overlap = clampNumber(chunking.overlap, 0, Math.max(0, chunking.tokens - 1));
|
const overlap = Math.max(0, Math.min(chunking.overlap, chunking.tokens - 1));
|
||||||
const minScore = clampNumber(query.minScore, 0, 1);
|
const minScore = Math.max(0, Math.min(1, query.minScore));
|
||||||
const vectorWeight = clampNumber(hybrid.vectorWeight, 0, 1);
|
const vectorWeight = Math.max(0, Math.min(1, hybrid.vectorWeight));
|
||||||
const textWeight = clampNumber(hybrid.textWeight, 0, 1);
|
const textWeight = Math.max(0, Math.min(1, hybrid.textWeight));
|
||||||
const sum = vectorWeight + textWeight;
|
const sum = vectorWeight + textWeight;
|
||||||
const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT;
|
const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT;
|
||||||
const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
|
const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
|
||||||
const candidateMultiplier = clampInt(hybrid.candidateMultiplier, 1, 20);
|
const candidateMultiplier = Math.max(1, Math.min(20, Math.floor(hybrid.candidateMultiplier)));
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
sources,
|
sources,
|
||||||
|
|||||||
@@ -26,11 +26,6 @@ describe("classifyFailoverReason", () => {
|
|||||||
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
|
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
|
||||||
expect(classifyFailoverReason("string should match pattern")).toBe("format");
|
expect(classifyFailoverReason("string should match pattern")).toBe("format");
|
||||||
expect(classifyFailoverReason("bad request")).toBeNull();
|
expect(classifyFailoverReason("bad request")).toBeNull();
|
||||||
expect(
|
|
||||||
classifyFailoverReason(
|
|
||||||
"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels",
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
});
|
||||||
it("classifies OpenAI usage limit errors as rate_limit", () => {
|
it("classifies OpenAI usage limit errors as rate_limit", () => {
|
||||||
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
|
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { isImageDimensionErrorMessage, parseImageDimensionError } from "./pi-embedded-helpers.js";
|
|
||||||
|
|
||||||
describe("image dimension errors", () => {
|
|
||||||
it("parses anthropic image dimension errors", () => {
|
|
||||||
const raw =
|
|
||||||
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}";
|
|
||||||
const parsed = parseImageDimensionError(raw);
|
|
||||||
expect(parsed).not.toBeNull();
|
|
||||||
expect(parsed?.maxDimensionPx).toBe(2000);
|
|
||||||
expect(parsed?.messageIndex).toBe(84);
|
|
||||||
expect(parsed?.contentIndex).toBe(1);
|
|
||||||
expect(isImageDimensionErrorMessage(raw)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -23,10 +23,5 @@ describe("isCloudCodeAssistFormatError", () => {
|
|||||||
});
|
});
|
||||||
it("ignores unrelated errors", () => {
|
it("ignores unrelated errors", () => {
|
||||||
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
|
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
|
||||||
expect(
|
|
||||||
isCloudCodeAssistFormatError(
|
|
||||||
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}",
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -21,13 +21,11 @@ export {
|
|||||||
isContextOverflowError,
|
isContextOverflowError,
|
||||||
isFailoverAssistantError,
|
isFailoverAssistantError,
|
||||||
isFailoverErrorMessage,
|
isFailoverErrorMessage,
|
||||||
isImageDimensionErrorMessage,
|
|
||||||
isOverloadedErrorMessage,
|
isOverloadedErrorMessage,
|
||||||
isRawApiErrorPayload,
|
isRawApiErrorPayload,
|
||||||
isRateLimitAssistantError,
|
isRateLimitAssistantError,
|
||||||
isRateLimitErrorMessage,
|
isRateLimitErrorMessage,
|
||||||
isTimeoutErrorMessage,
|
isTimeoutErrorMessage,
|
||||||
parseImageDimensionError,
|
|
||||||
} from "./pi-embedded-helpers/errors.js";
|
} from "./pi-embedded-helpers/errors.js";
|
||||||
export {
|
export {
|
||||||
downgradeGeminiHistory,
|
downgradeGeminiHistory,
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ const ERROR_PATTERNS = {
|
|||||||
"no api key found",
|
"no api key found",
|
||||||
],
|
],
|
||||||
format: [
|
format: [
|
||||||
|
"invalid_request_error",
|
||||||
"string should match pattern",
|
"string should match pattern",
|
||||||
"tool_use.id",
|
"tool_use.id",
|
||||||
"tool_use_id",
|
"tool_use_id",
|
||||||
@@ -347,10 +348,6 @@ const ERROR_PATTERNS = {
|
|||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const IMAGE_DIMENSION_ERROR_RE =
|
|
||||||
/image dimensions exceed max allowed size for many-image requests:\s*(\d+)\s*pixels/i;
|
|
||||||
const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i;
|
|
||||||
|
|
||||||
function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
|
function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
|
||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
const value = raw.toLowerCase();
|
const value = raw.toLowerCase();
|
||||||
@@ -393,31 +390,8 @@ export function isOverloadedErrorMessage(raw: string): boolean {
|
|||||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded);
|
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseImageDimensionError(raw: string): {
|
|
||||||
maxDimensionPx?: number;
|
|
||||||
messageIndex?: number;
|
|
||||||
contentIndex?: number;
|
|
||||||
raw: string;
|
|
||||||
} | null {
|
|
||||||
if (!raw) return null;
|
|
||||||
const lower = raw.toLowerCase();
|
|
||||||
if (!lower.includes("image dimensions exceed max allowed size")) return null;
|
|
||||||
const limitMatch = raw.match(IMAGE_DIMENSION_ERROR_RE);
|
|
||||||
const pathMatch = raw.match(IMAGE_DIMENSION_PATH_RE);
|
|
||||||
return {
|
|
||||||
maxDimensionPx: limitMatch?.[1] ? Number.parseInt(limitMatch[1], 10) : undefined,
|
|
||||||
messageIndex: pathMatch?.[1] ? Number.parseInt(pathMatch[1], 10) : undefined,
|
|
||||||
contentIndex: pathMatch?.[2] ? Number.parseInt(pathMatch[2], 10) : undefined,
|
|
||||||
raw,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isImageDimensionErrorMessage(raw: string): boolean {
|
|
||||||
return Boolean(parseImageDimensionError(raw));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCloudCodeAssistFormatError(raw: string): boolean {
|
export function isCloudCodeAssistFormatError(raw: string): boolean {
|
||||||
return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format);
|
return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
|
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
|
||||||
@@ -426,7 +400,6 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function classifyFailoverReason(raw: string): FailoverReason | null {
|
export function classifyFailoverReason(raw: string): FailoverReason | null {
|
||||||
if (isImageDimensionErrorMessage(raw)) return null;
|
|
||||||
if (isRateLimitErrorMessage(raw)) return "rate_limit";
|
if (isRateLimitErrorMessage(raw)) return "rate_limit";
|
||||||
if (isOverloadedErrorMessage(raw)) return "rate_limit";
|
if (isOverloadedErrorMessage(raw)) return "rate_limit";
|
||||||
if (isCloudCodeAssistFormatError(raw)) return "format";
|
if (isCloudCodeAssistFormatError(raw)) return "format";
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { resolveUserPath } from "../../utils.js";
|
|||||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||||
import { resolveSessionAgentIds } from "../agent-scope.js";
|
import { resolveSessionAgentIds } from "../agent-scope.js";
|
||||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
|
||||||
import { resolveClawdbotDocsPath } from "../docs-path.js";
|
|
||||||
import type { ExecElevatedDefaults } from "../bash-tools.js";
|
import type { ExecElevatedDefaults } from "../bash-tools.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
||||||
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
|
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
|
||||||
@@ -251,12 +250,6 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
});
|
});
|
||||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||||
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||||
const docsPath = await resolveClawdbotDocsPath({
|
|
||||||
workspaceDir: effectiveWorkspace,
|
|
||||||
argv1: process.argv[1],
|
|
||||||
cwd: process.cwd(),
|
|
||||||
moduleUrl: import.meta.url,
|
|
||||||
});
|
|
||||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
defaultThinkLevel: params.thinkLevel,
|
defaultThinkLevel: params.thinkLevel,
|
||||||
@@ -268,7 +261,6 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||||
: undefined,
|
: undefined,
|
||||||
skillsPrompt,
|
skillsPrompt,
|
||||||
docsPath: docsPath ?? undefined,
|
|
||||||
promptMode,
|
promptMode,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
sandboxInfo,
|
sandboxInfo,
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
isContextOverflowError,
|
isContextOverflowError,
|
||||||
isFailoverAssistantError,
|
isFailoverAssistantError,
|
||||||
isFailoverErrorMessage,
|
isFailoverErrorMessage,
|
||||||
parseImageDimensionError,
|
|
||||||
isRateLimitAssistantError,
|
isRateLimitAssistantError,
|
||||||
isTimeoutErrorMessage,
|
isTimeoutErrorMessage,
|
||||||
pickFallbackThinkingLevel,
|
pickFallbackThinkingLevel,
|
||||||
@@ -358,26 +357,6 @@ export async function runEmbeddedPiAgent(
|
|||||||
const failoverFailure = isFailoverAssistantError(lastAssistant);
|
const failoverFailure = isFailoverAssistantError(lastAssistant);
|
||||||
const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? "");
|
const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? "");
|
||||||
const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError;
|
const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError;
|
||||||
const imageDimensionError = parseImageDimensionError(lastAssistant?.errorMessage ?? "");
|
|
||||||
|
|
||||||
if (imageDimensionError && lastProfileId) {
|
|
||||||
const details = [
|
|
||||||
imageDimensionError.messageIndex !== undefined
|
|
||||||
? `message=${imageDimensionError.messageIndex}`
|
|
||||||
: null,
|
|
||||||
imageDimensionError.contentIndex !== undefined
|
|
||||||
? `content=${imageDimensionError.contentIndex}`
|
|
||||||
: null,
|
|
||||||
imageDimensionError.maxDimensionPx !== undefined
|
|
||||||
? `limit=${imageDimensionError.maxDimensionPx}px`
|
|
||||||
: null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
log.warn(
|
|
||||||
`Profile ${lastProfileId} rejected image payload${details ? ` (${details})` : ""}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Treat timeout as potential rate limit (Antigravity hangs on rate limit)
|
// Treat timeout as potential rate limit (Antigravity hangs on rate limit)
|
||||||
const shouldRotate = (!aborted && failoverFailure) || timedOut;
|
const shouldRotate = (!aborted && failoverFailure) || timedOut;
|
||||||
@@ -453,6 +432,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
toolMetas: attempt.toolMetas,
|
toolMetas: attempt.toolMetas,
|
||||||
lastAssistant: attempt.lastAssistant,
|
lastAssistant: attempt.lastAssistant,
|
||||||
lastToolError: attempt.lastToolError,
|
lastToolError: attempt.lastToolError,
|
||||||
|
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
verboseLevel: params.verboseLevel,
|
verboseLevel: params.verboseLevel,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { resolveUserPath } from "../../../utils.js";
|
|||||||
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
||||||
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
||||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
||||||
import { resolveClawdbotDocsPath } from "../../docs-path.js";
|
|
||||||
import { resolveModelAuthMode } from "../../model-auth.js";
|
import { resolveModelAuthMode } from "../../model-auth.js";
|
||||||
import {
|
import {
|
||||||
isCloudCodeAssistFormatError,
|
isCloudCodeAssistFormatError,
|
||||||
@@ -217,12 +216,6 @@ export async function runEmbeddedAttempt(
|
|||||||
});
|
});
|
||||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||||
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||||
const docsPath = await resolveClawdbotDocsPath({
|
|
||||||
workspaceDir: effectiveWorkspace,
|
|
||||||
argv1: process.argv[1],
|
|
||||||
cwd: process.cwd(),
|
|
||||||
moduleUrl: import.meta.url,
|
|
||||||
});
|
|
||||||
|
|
||||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
@@ -235,7 +228,6 @@ export async function runEmbeddedAttempt(
|
|||||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||||
: undefined,
|
: undefined,
|
||||||
skillsPrompt,
|
skillsPrompt,
|
||||||
docsPath: docsPath ?? undefined,
|
|
||||||
reactionGuidance,
|
reactionGuidance,
|
||||||
promptMode,
|
promptMode,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
|
|||||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||||
|
|
||||||
import { assertSandboxPath } from "../../sandbox-paths.js";
|
import { assertSandboxPath } from "../../sandbox-paths.js";
|
||||||
import { sanitizeImageBlocks } from "../../tool-images.js";
|
|
||||||
import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
|
import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
|
||||||
import { loadWebMedia } from "../../../web/media.js";
|
import { loadWebMedia } from "../../../web/media.js";
|
||||||
import { resolveUserPath } from "../../../utils.js";
|
import { resolveUserPath } from "../../../utils.js";
|
||||||
@@ -49,17 +48,6 @@ function isImageExtension(filePath: string): boolean {
|
|||||||
return IMAGE_EXTENSIONS.has(ext);
|
return IMAGE_EXTENSIONS.has(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sanitizeImagesWithLog(
|
|
||||||
images: ImageContent[],
|
|
||||||
label: string,
|
|
||||||
): Promise<ImageContent[]> {
|
|
||||||
const { images: sanitized, dropped } = await sanitizeImageBlocks(images, label);
|
|
||||||
if (dropped > 0) {
|
|
||||||
log.warn(`Native image: dropped ${dropped} image(s) after sanitization (${label}).`);
|
|
||||||
}
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects image references in a user prompt.
|
* Detects image references in a user prompt.
|
||||||
*
|
*
|
||||||
@@ -404,18 +392,9 @@ export async function detectAndLoadPromptImages(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedPromptImages = await sanitizeImagesWithLog(promptImages, "prompt:images");
|
|
||||||
const sanitizedHistoryImagesByIndex = new Map<number, ImageContent[]>();
|
|
||||||
for (const [index, images] of historyImagesByIndex) {
|
|
||||||
const sanitized = await sanitizeImagesWithLog(images, `history:images:${index}`);
|
|
||||||
if (sanitized.length > 0) {
|
|
||||||
sanitizedHistoryImagesByIndex.set(index, sanitized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
images: sanitizedPromptImages,
|
images: promptImages,
|
||||||
historyImagesByIndex: sanitizedHistoryImagesByIndex,
|
historyImagesByIndex,
|
||||||
detectedRefs: allRefs,
|
detectedRefs: allRefs,
|
||||||
loadedCount,
|
loadedCount,
|
||||||
skippedCount,
|
skippedCount,
|
||||||
|
|||||||
@@ -147,4 +147,40 @@ describe("buildEmbeddedRunPayloads", () => {
|
|||||||
expect(payloads).toHaveLength(1);
|
expect(payloads).toHaveLength(1);
|
||||||
expect(payloads[0]?.text).toBe("All good");
|
expect(payloads[0]?.text).toBe("All good");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds tool error fallback when assistant output is NO_REPLY", () => {
|
||||||
|
const payloads = buildEmbeddedRunPayloads({
|
||||||
|
assistantTexts: ["NO_REPLY"],
|
||||||
|
toolMetas: [],
|
||||||
|
lastAssistant: { stopReason: "end_turn" } as AssistantMessage,
|
||||||
|
lastToolError: { toolName: "browser", error: "tab not found" },
|
||||||
|
sessionKey: "session:telegram",
|
||||||
|
inlineToolResultsAllowed: false,
|
||||||
|
verboseLevel: "off",
|
||||||
|
reasoningLevel: "off",
|
||||||
|
toolResultFormat: "plain",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payloads).toHaveLength(1);
|
||||||
|
expect(payloads[0]?.isError).toBe(true);
|
||||||
|
expect(payloads[0]?.text).toContain("browser");
|
||||||
|
expect(payloads[0]?.text).toContain("tab not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips tool error fallback when messaging tool already sent", () => {
|
||||||
|
const payloads = buildEmbeddedRunPayloads({
|
||||||
|
assistantTexts: [],
|
||||||
|
toolMetas: [],
|
||||||
|
lastAssistant: undefined,
|
||||||
|
lastToolError: { toolName: "browser", error: "tab not found" },
|
||||||
|
didSendViaMessagingTool: true,
|
||||||
|
sessionKey: "session:telegram",
|
||||||
|
inlineToolResultsAllowed: false,
|
||||||
|
verboseLevel: "off",
|
||||||
|
reasoningLevel: "off",
|
||||||
|
toolResultFormat: "plain",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payloads).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -24,6 +24,7 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
toolMetas: ToolMetaEntry[];
|
toolMetas: ToolMetaEntry[];
|
||||||
lastAssistant: AssistantMessage | undefined;
|
lastAssistant: AssistantMessage | undefined;
|
||||||
lastToolError?: { toolName: string; meta?: string; error?: string };
|
lastToolError?: { toolName: string; meta?: string; error?: string };
|
||||||
|
didSendViaMessagingTool?: boolean;
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
verboseLevel?: VerboseLevel;
|
verboseLevel?: VerboseLevel;
|
||||||
@@ -156,34 +157,46 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replyItems.length === 0 && params.lastToolError) {
|
const buildPayloads = (items: typeof replyItems) => {
|
||||||
|
const hasAudioAsVoiceTag = items.some((item) => item.audioAsVoice);
|
||||||
|
return items
|
||||||
|
.map((item) => ({
|
||||||
|
text: item.text?.trim() ? item.text.trim() : undefined,
|
||||||
|
mediaUrls: item.media?.length ? item.media : undefined,
|
||||||
|
mediaUrl: item.media?.[0],
|
||||||
|
isError: item.isError,
|
||||||
|
replyToId: item.replyToId,
|
||||||
|
replyToTag: item.replyToTag,
|
||||||
|
replyToCurrent: item.replyToCurrent,
|
||||||
|
audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length),
|
||||||
|
}))
|
||||||
|
.filter((p) => {
|
||||||
|
if (!p.text && !p.mediaUrl && (!p.mediaUrls || p.mediaUrls.length === 0)) return false;
|
||||||
|
if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let payloads = buildPayloads(replyItems);
|
||||||
|
|
||||||
|
if (
|
||||||
|
payloads.length === 0 &&
|
||||||
|
params.lastToolError &&
|
||||||
|
params.didSendViaMessagingTool !== true
|
||||||
|
) {
|
||||||
const toolSummary = formatToolAggregate(
|
const toolSummary = formatToolAggregate(
|
||||||
params.lastToolError.toolName,
|
params.lastToolError.toolName,
|
||||||
params.lastToolError.meta ? [params.lastToolError.meta] : undefined,
|
params.lastToolError.meta ? [params.lastToolError.meta] : undefined,
|
||||||
{ markdown: useMarkdown },
|
{ markdown: useMarkdown },
|
||||||
);
|
);
|
||||||
const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : "";
|
const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : "";
|
||||||
replyItems.push({
|
payloads = buildPayloads([
|
||||||
text: `⚠️ ${toolSummary} failed${errorSuffix}`,
|
{
|
||||||
isError: true,
|
text: `⚠️ ${toolSummary} failed${errorSuffix}`,
|
||||||
});
|
isError: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice);
|
return payloads;
|
||||||
return replyItems
|
|
||||||
.map((item) => ({
|
|
||||||
text: item.text?.trim() ? item.text.trim() : undefined,
|
|
||||||
mediaUrls: item.media?.length ? item.media : undefined,
|
|
||||||
mediaUrl: item.media?.[0],
|
|
||||||
isError: item.isError,
|
|
||||||
replyToId: item.replyToId,
|
|
||||||
replyToTag: item.replyToTag,
|
|
||||||
replyToCurrent: item.replyToCurrent,
|
|
||||||
audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length),
|
|
||||||
}))
|
|
||||||
.filter((p) => {
|
|
||||||
if (!p.text && !p.mediaUrl && (!p.mediaUrls || p.mediaUrls.length === 0)) return false;
|
|
||||||
if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,6 @@ export function buildEmbeddedSystemPrompt(params: {
|
|||||||
reasoningTagHint: boolean;
|
reasoningTagHint: boolean;
|
||||||
heartbeatPrompt?: string;
|
heartbeatPrompt?: string;
|
||||||
skillsPrompt?: string;
|
skillsPrompt?: string;
|
||||||
docsPath?: string;
|
|
||||||
reactionGuidance?: {
|
reactionGuidance?: {
|
||||||
level: "minimal" | "extensive";
|
level: "minimal" | "extensive";
|
||||||
channel: string;
|
channel: string;
|
||||||
@@ -49,7 +48,6 @@ export function buildEmbeddedSystemPrompt(params: {
|
|||||||
reasoningTagHint: params.reasoningTagHint,
|
reasoningTagHint: params.reasoningTagHint,
|
||||||
heartbeatPrompt: params.heartbeatPrompt,
|
heartbeatPrompt: params.heartbeatPrompt,
|
||||||
skillsPrompt: params.skillsPrompt,
|
skillsPrompt: params.skillsPrompt,
|
||||||
docsPath: params.docsPath,
|
|
||||||
reactionGuidance: params.reactionGuidance,
|
reactionGuidance: params.reactionGuidance,
|
||||||
promptMode: params.promptMode,
|
promptMode: params.promptMode,
|
||||||
runtimeInfo: params.runtimeInfo,
|
runtimeInfo: params.runtimeInfo,
|
||||||
|
|||||||
@@ -32,14 +32,12 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
|
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
|
||||||
heartbeatPrompt: "ping",
|
heartbeatPrompt: "ping",
|
||||||
toolNames: ["message", "memory_search"],
|
toolNames: ["message", "memory_search"],
|
||||||
docsPath: "/tmp/clawd/docs",
|
|
||||||
extraSystemPrompt: "Subagent details",
|
extraSystemPrompt: "Subagent details",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).not.toContain("## User Identity");
|
expect(prompt).not.toContain("## User Identity");
|
||||||
expect(prompt).not.toContain("## Skills");
|
expect(prompt).not.toContain("## Skills");
|
||||||
expect(prompt).not.toContain("## Memory Recall");
|
expect(prompt).not.toContain("## Memory Recall");
|
||||||
expect(prompt).not.toContain("## Documentation");
|
|
||||||
expect(prompt).not.toContain("## Reply Tags");
|
expect(prompt).not.toContain("## Reply Tags");
|
||||||
expect(prompt).not.toContain("## Messaging");
|
expect(prompt).not.toContain("## Messaging");
|
||||||
expect(prompt).not.toContain("## Silent Replies");
|
expect(prompt).not.toContain("## Silent Replies");
|
||||||
@@ -88,7 +86,6 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
toolNames: ["Read", "Exec", "process"],
|
toolNames: ["Read", "Exec", "process"],
|
||||||
skillsPrompt:
|
skillsPrompt:
|
||||||
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
|
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
|
||||||
docsPath: "/tmp/clawd/docs",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).toContain("- Read: Read file contents");
|
expect(prompt).toContain("- Read: Read file contents");
|
||||||
@@ -96,23 +93,6 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
"Use `Read` to load the SKILL.md at the location listed for that skill.",
|
"Use `Read` to load the SKILL.md at the location listed for that skill.",
|
||||||
);
|
);
|
||||||
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
|
|
||||||
expect(prompt).toContain(
|
|
||||||
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes docs guidance when docsPath is provided", () => {
|
|
||||||
const prompt = buildAgentSystemPrompt({
|
|
||||||
workspaceDir: "/tmp/clawd",
|
|
||||||
docsPath: "/tmp/clawd/docs",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(prompt).toContain("## Documentation");
|
|
||||||
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
|
|
||||||
expect(prompt).toContain(
|
|
||||||
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes user time when provided (12-hour)", () => {
|
it("includes user time when provided (12-hour)", () => {
|
||||||
|
|||||||
@@ -109,26 +109,6 @@ function buildMessagingSection(params: {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDocsSection(params: {
|
|
||||||
docsPath?: string;
|
|
||||||
isMinimal: boolean;
|
|
||||||
readToolName: string;
|
|
||||||
}) {
|
|
||||||
const docsPath = params.docsPath?.trim();
|
|
||||||
if (!docsPath || params.isMinimal) return [];
|
|
||||||
return [
|
|
||||||
"## Documentation",
|
|
||||||
`Clawdbot docs: ${docsPath}`,
|
|
||||||
"Mirror: https://docs.clawd.bot",
|
|
||||||
"Source: https://github.com/clawdbot/clawdbot",
|
|
||||||
"Community: https://discord.com/invite/clawd",
|
|
||||||
"Find new skills: https://clawdhub.com",
|
|
||||||
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
|
|
||||||
"When diagnosing issues, run `clawdbot status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).",
|
|
||||||
"",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAgentSystemPrompt(params: {
|
export function buildAgentSystemPrompt(params: {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
defaultThinkLevel?: ThinkLevel;
|
defaultThinkLevel?: ThinkLevel;
|
||||||
@@ -145,7 +125,6 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
contextFiles?: EmbeddedContextFile[];
|
contextFiles?: EmbeddedContextFile[];
|
||||||
skillsPrompt?: string;
|
skillsPrompt?: string;
|
||||||
heartbeatPrompt?: string;
|
heartbeatPrompt?: string;
|
||||||
docsPath?: string;
|
|
||||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||||
promptMode?: PromptMode;
|
promptMode?: PromptMode;
|
||||||
runtimeInfo?: {
|
runtimeInfo?: {
|
||||||
@@ -316,11 +295,6 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
readToolName,
|
readToolName,
|
||||||
});
|
});
|
||||||
const memorySection = buildMemorySection({ isMinimal, availableTools });
|
const memorySection = buildMemorySection({ isMinimal, availableTools });
|
||||||
const docsSection = buildDocsSection({
|
|
||||||
docsPath: params.docsPath,
|
|
||||||
isMinimal,
|
|
||||||
readToolName,
|
|
||||||
});
|
|
||||||
|
|
||||||
// For "none" mode, return just the basic identity line
|
// For "none" mode, return just the basic identity line
|
||||||
if (promptMode === "none") {
|
if (promptMode === "none") {
|
||||||
@@ -397,7 +371,6 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
`Your working directory is: ${params.workspaceDir}`,
|
`Your working directory is: ${params.workspaceDir}`,
|
||||||
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
||||||
"",
|
"",
|
||||||
...docsSection,
|
|
||||||
params.sandboxInfo?.enabled ? "## Sandbox" : "",
|
params.sandboxInfo?.enabled ? "## Sandbox" : "",
|
||||||
params.sandboxInfo?.enabled
|
params.sandboxInfo?.enabled
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { sanitizeContentBlocksImages, sanitizeImageBlocks } from "./tool-images.js";
|
import { sanitizeContentBlocksImages } from "./tool-images.js";
|
||||||
|
|
||||||
describe("tool image sanitizing", () => {
|
describe("tool image sanitizing", () => {
|
||||||
it("shrinks oversized images to <=5MB", async () => {
|
it("shrinks oversized images to <=5MB", async () => {
|
||||||
@@ -33,56 +33,6 @@ describe("tool image sanitizing", () => {
|
|||||||
expect(image.mimeType).toBe("image/jpeg");
|
expect(image.mimeType).toBe("image/jpeg");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("sanitizes image arrays and reports drops", async () => {
|
|
||||||
const width = 2600;
|
|
||||||
const height = 400;
|
|
||||||
const raw = Buffer.alloc(width * height * 3, 0x7f);
|
|
||||||
const png = await sharp(raw, {
|
|
||||||
raw: { width, height, channels: 3 },
|
|
||||||
})
|
|
||||||
.png({ compressionLevel: 9 })
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
const images = [
|
|
||||||
{ type: "image" as const, data: png.toString("base64"), mimeType: "image/png" },
|
|
||||||
];
|
|
||||||
const { images: out, dropped } = await sanitizeImageBlocks(images, "test");
|
|
||||||
expect(dropped).toBe(0);
|
|
||||||
expect(out.length).toBe(1);
|
|
||||||
const meta = await sharp(Buffer.from(out[0].data, "base64")).metadata();
|
|
||||||
expect(meta.width).toBeLessThanOrEqual(2000);
|
|
||||||
expect(meta.height).toBeLessThanOrEqual(2000);
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
it("shrinks images that exceed max dimension even if size is small", async () => {
|
|
||||||
const width = 2600;
|
|
||||||
const height = 400;
|
|
||||||
const raw = Buffer.alloc(width * height * 3, 0x7f);
|
|
||||||
const png = await sharp(raw, {
|
|
||||||
raw: { width, height, channels: 3 },
|
|
||||||
})
|
|
||||||
.png({ compressionLevel: 9 })
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
const blocks = [
|
|
||||||
{
|
|
||||||
type: "image" as const,
|
|
||||||
data: png.toString("base64"),
|
|
||||||
mimeType: "image/png",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const out = await sanitizeContentBlocksImages(blocks, "test");
|
|
||||||
const image = out.find((b) => b.type === "image");
|
|
||||||
if (!image || image.type !== "image") {
|
|
||||||
throw new Error("expected image block");
|
|
||||||
}
|
|
||||||
const meta = await sharp(Buffer.from(image.data, "base64")).metadata();
|
|
||||||
expect(meta.width).toBeLessThanOrEqual(2000);
|
|
||||||
expect(meta.height).toBeLessThanOrEqual(2000);
|
|
||||||
expect(image.mimeType).toBe("image/jpeg");
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
it("corrects mismatched jpeg mimeType", async () => {
|
it("corrects mismatched jpeg mimeType", async () => {
|
||||||
const jpeg = await sharp({
|
const jpeg = await sharp({
|
||||||
create: {
|
create: {
|
||||||
|
|||||||
+12
-55
@@ -1,7 +1,5 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
|
||||||
|
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
|
||||||
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
|
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
|
||||||
|
|
||||||
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
|
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
|
||||||
@@ -16,7 +14,6 @@ type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
|
|||||||
// and recompress base64 image blocks when they exceed these limits.
|
// and recompress base64 image blocks when they exceed these limits.
|
||||||
const MAX_IMAGE_DIMENSION_PX = 2000;
|
const MAX_IMAGE_DIMENSION_PX = 2000;
|
||||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||||
const log = createSubsystemLogger("agents/tool-images");
|
|
||||||
|
|
||||||
function isImageBlock(block: unknown): block is ImageContentBlock {
|
function isImageBlock(block: unknown): block is ImageContentBlock {
|
||||||
if (!block || typeof block !== "object") return false;
|
if (!block || typeof block !== "object") return false;
|
||||||
@@ -44,41 +41,26 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
maxDimensionPx: number;
|
maxDimensionPx: number;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
label?: string;
|
}): Promise<{ base64: string; mimeType: string; resized: boolean }> {
|
||||||
}): Promise<{
|
|
||||||
base64: string;
|
|
||||||
mimeType: string;
|
|
||||||
resized: boolean;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
}> {
|
|
||||||
const buf = Buffer.from(params.base64, "base64");
|
const buf = Buffer.from(params.base64, "base64");
|
||||||
const meta = await getImageMetadata(buf);
|
const meta = await getImageMetadata(buf);
|
||||||
const width = meta?.width;
|
const width = meta?.width;
|
||||||
const height = meta?.height;
|
const height = meta?.height;
|
||||||
const overBytes = buf.byteLength > params.maxBytes;
|
const overBytes = buf.byteLength > params.maxBytes;
|
||||||
const hasDimensions = typeof width === "number" && typeof height === "number";
|
const maxDim = Math.max(width ?? 0, height ?? 0);
|
||||||
if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
|
if (typeof width !== "number" || typeof height !== "number") {
|
||||||
return {
|
if (!overBytes) {
|
||||||
base64: params.base64,
|
return {
|
||||||
mimeType: params.mimeType,
|
base64: params.base64,
|
||||||
resized: false,
|
mimeType: params.mimeType,
|
||||||
width,
|
resized: false,
|
||||||
height,
|
};
|
||||||
};
|
}
|
||||||
}
|
} else if (!overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
|
||||||
if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) {
|
return { base64: params.base64, mimeType: params.mimeType, resized: false };
|
||||||
log.warn("Image exceeds limits; resizing", {
|
|
||||||
label: params.label,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
maxDimensionPx: params.maxDimensionPx,
|
|
||||||
maxBytes: params.maxBytes,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const qualities = [85, 75, 65, 55, 45, 35];
|
const qualities = [85, 75, 65, 55, 45, 35];
|
||||||
const maxDim = hasDimensions ? Math.max(width ?? 0, height ?? 0) : params.maxDimensionPx;
|
|
||||||
const sideStart = maxDim > 0 ? Math.min(params.maxDimensionPx, maxDim) : params.maxDimensionPx;
|
const sideStart = maxDim > 0 ? Math.min(params.maxDimensionPx, maxDim) : params.maxDimensionPx;
|
||||||
const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800]
|
const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800]
|
||||||
.map((v) => Math.min(params.maxDimensionPx, v))
|
.map((v) => Math.min(params.maxDimensionPx, v))
|
||||||
@@ -98,23 +80,10 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
smallest = { buffer: out, size: out.byteLength };
|
smallest = { buffer: out, size: out.byteLength };
|
||||||
}
|
}
|
||||||
if (out.byteLength <= params.maxBytes) {
|
if (out.byteLength <= params.maxBytes) {
|
||||||
log.info("Image resized", {
|
|
||||||
label: params.label,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
maxDimensionPx: params.maxDimensionPx,
|
|
||||||
maxBytes: params.maxBytes,
|
|
||||||
originalBytes: buf.byteLength,
|
|
||||||
resizedBytes: out.byteLength,
|
|
||||||
quality,
|
|
||||||
side,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
base64: out.toString("base64"),
|
base64: out.toString("base64"),
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
resized: true,
|
resized: true,
|
||||||
width,
|
|
||||||
height,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,7 +127,6 @@ export async function sanitizeContentBlocksImages(
|
|||||||
mimeType,
|
mimeType,
|
||||||
maxDimensionPx,
|
maxDimensionPx,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
label,
|
|
||||||
});
|
});
|
||||||
out.push({
|
out.push({
|
||||||
...block,
|
...block,
|
||||||
@@ -176,17 +144,6 @@ export async function sanitizeContentBlocksImages(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sanitizeImageBlocks(
|
|
||||||
images: ImageContent[],
|
|
||||||
label: string,
|
|
||||||
opts: { maxDimensionPx?: number; maxBytes?: number } = {},
|
|
||||||
): Promise<{ images: ImageContent[]; dropped: number }> {
|
|
||||||
if (images.length === 0) return { images, dropped: 0 };
|
|
||||||
const sanitized = await sanitizeContentBlocksImages(images as ToolContentBlock[], label, opts);
|
|
||||||
const next = sanitized.filter(isImageBlock) as ImageContent[];
|
|
||||||
return { images: next, dropped: Math.max(0, images.length - next.length) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sanitizeToolResultImages(
|
export async function sanitizeToolResultImages(
|
||||||
result: AgentToolResult<unknown>,
|
result: AgentToolResult<unknown>,
|
||||||
label: string,
|
label: string,
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ export type NodeListNode = {
|
|||||||
nodeId: string;
|
nodeId: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
|
||||||
coreVersion?: string;
|
|
||||||
uiVersion?: string;
|
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
deviceFamily?: string;
|
deviceFamily?: string;
|
||||||
modelIdentifier?: string;
|
modelIdentifier?: string;
|
||||||
@@ -23,8 +20,6 @@ type PendingRequest = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
coreVersion?: string;
|
|
||||||
uiVersion?: string;
|
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
isRepair?: boolean;
|
isRepair?: boolean;
|
||||||
ts: number;
|
ts: number;
|
||||||
@@ -36,8 +31,6 @@ type PairedNode = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
coreVersion?: string;
|
|
||||||
uiVersion?: string;
|
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
|||||||
import { killProcessTree } from "../../agents/shell-utils.js";
|
import { killProcessTree } from "../../agents/shell-utils.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { clampInt } from "../../utils.js";
|
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||||
@@ -32,10 +31,14 @@ type ActiveBashJob =
|
|||||||
|
|
||||||
let activeJob: ActiveBashJob | null = null;
|
let activeJob: ActiveBashJob | null = null;
|
||||||
|
|
||||||
|
function clampNumber(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveForegroundMs(cfg: ClawdbotConfig): number {
|
function resolveForegroundMs(cfg: ClawdbotConfig): number {
|
||||||
const raw = cfg.commands?.bashForegroundMs;
|
const raw = cfg.commands?.bashForegroundMs;
|
||||||
if (typeof raw !== "number" || Number.isNaN(raw)) return DEFAULT_FOREGROUND_MS;
|
if (typeof raw !== "number" || Number.isNaN(raw)) return DEFAULT_FOREGROUND_MS;
|
||||||
return clampInt(raw, 0, MAX_FOREGROUND_MS);
|
return clampNumber(Math.floor(raw), 0, MAX_FOREGROUND_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSessionSnippet(sessionId: string) {
|
function formatSessionSnippet(sessionId: string) {
|
||||||
@@ -186,7 +189,7 @@ export async function handleBashChatCommand(params: {
|
|||||||
}): Promise<ReplyPayload> {
|
}): Promise<ReplyPayload> {
|
||||||
if (params.cfg.commands?.bash !== true) {
|
if (params.cfg.commands?.bash !== true) {
|
||||||
return {
|
return {
|
||||||
text: "⚠️ bash is disabled. Set commands.bash=true to enable. Docs: https://docs.clawd.bot/tools/slash-commands#config",
|
text: "⚠️ bash is disabled. Set commands.bash=true to enable.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { Command } from "commander";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const callGatewayFromCli = vi.fn(
|
|
||||||
async (method: string, _opts: unknown, params?: unknown) => {
|
|
||||||
if (method.endsWith(".get")) {
|
|
||||||
return {
|
|
||||||
path: "/tmp/exec-approvals.json",
|
|
||||||
exists: true,
|
|
||||||
hash: "hash-1",
|
|
||||||
file: { version: 1, agents: {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { method, params };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const runtimeLogs: string[] = [];
|
|
||||||
const runtimeErrors: string[] = [];
|
|
||||||
const defaultRuntime = {
|
|
||||||
log: (msg: string) => runtimeLogs.push(msg),
|
|
||||||
error: (msg: string) => runtimeErrors.push(msg),
|
|
||||||
exit: (code: number) => {
|
|
||||||
throw new Error(`__exit__:${code}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("./gateway-rpc.js", () => ({
|
|
||||||
callGatewayFromCli: (method: string, opts: unknown, params?: unknown) =>
|
|
||||||
callGatewayFromCli(method, opts, params),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./nodes-cli/rpc.js", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>(
|
|
||||||
"./nodes-cli/rpc.js",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
resolveNodeId: vi.fn(async () => "node-1"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../runtime.js", () => ({
|
|
||||||
defaultRuntime,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("exec approvals CLI", () => {
|
|
||||||
it("loads gateway approvals by default", async () => {
|
|
||||||
runtimeLogs.length = 0;
|
|
||||||
runtimeErrors.length = 0;
|
|
||||||
callGatewayFromCli.mockClear();
|
|
||||||
|
|
||||||
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerExecApprovalsCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["approvals", "get"], { from: "user" });
|
|
||||||
|
|
||||||
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
|
||||||
"exec.approvals.get",
|
|
||||||
expect.anything(),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
expect(runtimeErrors).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads node approvals when --node is set", async () => {
|
|
||||||
runtimeLogs.length = 0;
|
|
||||||
runtimeErrors.length = 0;
|
|
||||||
callGatewayFromCli.mockClear();
|
|
||||||
|
|
||||||
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerExecApprovalsCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
|
|
||||||
|
|
||||||
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
|
||||||
"exec.approvals.node.get",
|
|
||||||
expect.anything(),
|
|
||||||
{ nodeId: "node-1" },
|
|
||||||
);
|
|
||||||
expect(runtimeErrors).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import JSON5 from "json5";
|
|
||||||
import type { Command } from "commander";
|
|
||||||
|
|
||||||
import type { ExecApprovalsAgent, ExecApprovalsFile } from "../infra/exec-approvals.js";
|
|
||||||
import { defaultRuntime } from "../runtime.js";
|
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
|
||||||
import { theme } from "../terminal/theme.js";
|
|
||||||
import { callGatewayFromCli } from "./gateway-rpc.js";
|
|
||||||
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
|
|
||||||
import type { NodesRpcOpts } from "./nodes-cli/types.js";
|
|
||||||
|
|
||||||
type ExecApprovalsSnapshot = {
|
|
||||||
path: string;
|
|
||||||
exists: boolean;
|
|
||||||
hash: string;
|
|
||||||
file: ExecApprovalsFile;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExecApprovalsCliOpts = NodesRpcOpts & {
|
|
||||||
node?: string;
|
|
||||||
file?: string;
|
|
||||||
stdin?: boolean;
|
|
||||||
agent?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function readStdin(): Promise<string> {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of process.stdin) {
|
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
||||||
}
|
|
||||||
return Buffer.concat(chunks).toString("utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveTargetNodeId(opts: ExecApprovalsCliOpts): Promise<string | null> {
|
|
||||||
const raw = opts.node?.trim() ?? "";
|
|
||||||
if (!raw) return null;
|
|
||||||
return await resolveNodeId(opts as NodesRpcOpts, raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSnapshot(
|
|
||||||
opts: ExecApprovalsCliOpts,
|
|
||||||
nodeId: string | null,
|
|
||||||
): Promise<ExecApprovalsSnapshot> {
|
|
||||||
const method = nodeId ? "exec.approvals.node.get" : "exec.approvals.get";
|
|
||||||
const params = nodeId ? { nodeId } : {};
|
|
||||||
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSnapshot(
|
|
||||||
opts: ExecApprovalsCliOpts,
|
|
||||||
nodeId: string | null,
|
|
||||||
file: ExecApprovalsFile,
|
|
||||||
baseHash: string,
|
|
||||||
): Promise<ExecApprovalsSnapshot> {
|
|
||||||
const method = nodeId ? "exec.approvals.node.set" : "exec.approvals.set";
|
|
||||||
const params = nodeId ? { nodeId, file, baseHash } : { file, baseHash };
|
|
||||||
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentKey(value?: string | null): string {
|
|
||||||
const trimmed = value?.trim() ?? "";
|
|
||||||
return trimmed ? trimmed : "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null {
|
|
||||||
const pattern = entry?.pattern?.trim() ?? "";
|
|
||||||
return pattern ? pattern : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureAgent(file: ExecApprovalsFile, agentKey: string): ExecApprovalsAgent {
|
|
||||||
const agents = file.agents ?? {};
|
|
||||||
const entry = agents[agentKey] ?? {};
|
|
||||||
file.agents = agents;
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEmptyAgent(agent: ExecApprovalsAgent): boolean {
|
|
||||||
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
|
||||||
return (
|
|
||||||
!agent.security &&
|
|
||||||
!agent.ask &&
|
|
||||||
!agent.askFallback &&
|
|
||||||
agent.autoAllowSkills === undefined &&
|
|
||||||
allowlist.length === 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerExecApprovalsCli(program: Command) {
|
|
||||||
const approvals = program
|
|
||||||
.command("approvals")
|
|
||||||
.alias("exec-approvals")
|
|
||||||
.description("Manage exec approvals (gateway or node host)")
|
|
||||||
.addHelpText(
|
|
||||||
"after",
|
|
||||||
() =>
|
|
||||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.clawd.bot/cli/approvals")}\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const getCmd = approvals
|
|
||||||
.command("get")
|
|
||||||
.description("Fetch exec approvals snapshot")
|
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
|
||||||
.action(async (opts: ExecApprovalsCliOpts) => {
|
|
||||||
const nodeId = await resolveTargetNodeId(opts);
|
|
||||||
const snapshot = await loadSnapshot(opts, nodeId);
|
|
||||||
const payload = opts.json ? JSON.stringify(snapshot) : JSON.stringify(snapshot, null, 2);
|
|
||||||
defaultRuntime.log(payload);
|
|
||||||
});
|
|
||||||
nodesCallOpts(getCmd);
|
|
||||||
|
|
||||||
const setCmd = approvals
|
|
||||||
.command("set")
|
|
||||||
.description("Replace exec approvals with a JSON file")
|
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
|
||||||
.option("--file <path>", "Path to JSON file to upload")
|
|
||||||
.option("--stdin", "Read JSON from stdin", false)
|
|
||||||
.action(async (opts: ExecApprovalsCliOpts) => {
|
|
||||||
if (!opts.file && !opts.stdin) {
|
|
||||||
defaultRuntime.error("Provide --file or --stdin.");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (opts.file && opts.stdin) {
|
|
||||||
defaultRuntime.error("Use either --file or --stdin (not both).");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nodeId = await resolveTargetNodeId(opts);
|
|
||||||
const snapshot = await loadSnapshot(opts, nodeId);
|
|
||||||
if (!snapshot.hash) {
|
|
||||||
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const raw = opts.stdin ? await readStdin() : await fs.readFile(String(opts.file), "utf8");
|
|
||||||
let file: ExecApprovalsFile;
|
|
||||||
try {
|
|
||||||
file = JSON5.parse(raw) as ExecApprovalsFile;
|
|
||||||
} catch (err) {
|
|
||||||
defaultRuntime.error(`Failed to parse approvals JSON: ${String(err)}`);
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
file.version = 1;
|
|
||||||
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
|
||||||
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
|
||||||
defaultRuntime.log(payload);
|
|
||||||
});
|
|
||||||
nodesCallOpts(setCmd);
|
|
||||||
|
|
||||||
const allowlist = approvals
|
|
||||||
.command("allowlist")
|
|
||||||
.description("Edit the per-agent allowlist");
|
|
||||||
|
|
||||||
const allowlistAdd = allowlist
|
|
||||||
.command("add <pattern>")
|
|
||||||
.description("Add a glob pattern to an allowlist")
|
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
|
||||||
.option("--agent <id>", "Agent id (defaults to \"default\")")
|
|
||||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
|
||||||
const trimmed = pattern.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
defaultRuntime.error("Pattern required.");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nodeId = await resolveTargetNodeId(opts);
|
|
||||||
const snapshot = await loadSnapshot(opts, nodeId);
|
|
||||||
if (!snapshot.hash) {
|
|
||||||
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const file = snapshot.file ?? { version: 1 };
|
|
||||||
file.version = 1;
|
|
||||||
const agentKey = resolveAgentKey(opts.agent);
|
|
||||||
const agent = ensureAgent(file, agentKey);
|
|
||||||
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
|
||||||
if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmed)) {
|
|
||||||
defaultRuntime.log("Already allowlisted.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() });
|
|
||||||
agent.allowlist = allowlistEntries;
|
|
||||||
file.agents = { ...file.agents, [agentKey]: agent };
|
|
||||||
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
|
||||||
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
|
||||||
defaultRuntime.log(payload);
|
|
||||||
});
|
|
||||||
nodesCallOpts(allowlistAdd);
|
|
||||||
|
|
||||||
const allowlistRemove = allowlist
|
|
||||||
.command("remove <pattern>")
|
|
||||||
.description("Remove a glob pattern from an allowlist")
|
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
|
||||||
.option("--agent <id>", "Agent id (defaults to \"default\")")
|
|
||||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
|
||||||
const trimmed = pattern.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
defaultRuntime.error("Pattern required.");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nodeId = await resolveTargetNodeId(opts);
|
|
||||||
const snapshot = await loadSnapshot(opts, nodeId);
|
|
||||||
if (!snapshot.hash) {
|
|
||||||
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const file = snapshot.file ?? { version: 1 };
|
|
||||||
file.version = 1;
|
|
||||||
const agentKey = resolveAgentKey(opts.agent);
|
|
||||||
const agent = ensureAgent(file, agentKey);
|
|
||||||
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
|
||||||
const nextEntries = allowlistEntries.filter(
|
|
||||||
(entry) => normalizeAllowlistEntry(entry) !== trimmed,
|
|
||||||
);
|
|
||||||
if (nextEntries.length === allowlistEntries.length) {
|
|
||||||
defaultRuntime.log("Pattern not found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nextEntries.length === 0) {
|
|
||||||
delete agent.allowlist;
|
|
||||||
} else {
|
|
||||||
agent.allowlist = nextEntries;
|
|
||||||
}
|
|
||||||
if (isEmptyAgent(agent)) {
|
|
||||||
const agents = { ...file.agents };
|
|
||||||
delete agents[agentKey];
|
|
||||||
file.agents = Object.keys(agents).length > 0 ? agents : undefined;
|
|
||||||
} else {
|
|
||||||
file.agents = { ...file.agents, [agentKey]: agent };
|
|
||||||
}
|
|
||||||
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
|
||||||
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
|
||||||
defaultRuntime.log(payload);
|
|
||||||
});
|
|
||||||
nodesCallOpts(allowlistRemove);
|
|
||||||
}
|
|
||||||
@@ -248,6 +248,69 @@ describe("memory cli", () => {
|
|||||||
expect(close).toHaveBeenCalled();
|
expect(close).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies index mode overrides", async () => {
|
||||||
|
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||||
|
const close = vi.fn(async () => {});
|
||||||
|
const sync = vi.fn(async () => {});
|
||||||
|
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
|
||||||
|
loadConfig.mockReturnValueOnce({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
memorySearch: {
|
||||||
|
remote: {
|
||||||
|
batch: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getMemorySearchManager.mockResolvedValueOnce({
|
||||||
|
manager: {
|
||||||
|
probeVectorAvailability: vi.fn(async () => true),
|
||||||
|
probeEmbeddingAvailability,
|
||||||
|
sync,
|
||||||
|
status: () => ({
|
||||||
|
files: 1,
|
||||||
|
chunks: 1,
|
||||||
|
dirty: false,
|
||||||
|
workspaceDir: "/tmp/clawd",
|
||||||
|
dbPath: "/tmp/memory.sqlite",
|
||||||
|
provider: "openai",
|
||||||
|
model: "text-embedding-3-small",
|
||||||
|
requestedProvider: "openai",
|
||||||
|
vector: { enabled: true, available: true },
|
||||||
|
}),
|
||||||
|
close,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
program.name("test");
|
||||||
|
registerMemoryCli(program);
|
||||||
|
await program.parseAsync(["memory", "status", "--index", "--index-mode", "direct"], {
|
||||||
|
from: "user",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getMemorySearchManager).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
cfg: expect.objectContaining({
|
||||||
|
agents: expect.objectContaining({
|
||||||
|
defaults: expect.objectContaining({
|
||||||
|
memorySearch: expect.objectContaining({
|
||||||
|
remote: expect.objectContaining({
|
||||||
|
batch: expect.objectContaining({ enabled: false }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sync).toHaveBeenCalled();
|
||||||
|
expect(probeEmbeddingAvailability).toHaveBeenCalled();
|
||||||
|
expect(close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("closes manager after index", async () => {
|
it("closes manager after index", async () => {
|
||||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
@@ -266,9 +329,31 @@ describe("memory cli", () => {
|
|||||||
registerMemoryCli(program);
|
registerMemoryCli(program);
|
||||||
await program.parseAsync(["memory", "index"], { from: "user" });
|
await program.parseAsync(["memory", "index"], { from: "user" });
|
||||||
|
|
||||||
|
expect(sync).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
|
||||||
|
);
|
||||||
|
expect(close).toHaveBeenCalled();
|
||||||
|
expect(log).toHaveBeenCalledWith("Memory index updated.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips progress when --progress none", async () => {
|
||||||
|
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||||
|
const close = vi.fn(async () => {});
|
||||||
|
const sync = vi.fn(async () => {});
|
||||||
|
getMemorySearchManager.mockResolvedValueOnce({
|
||||||
|
manager: {
|
||||||
|
sync,
|
||||||
|
close,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
program.name("test");
|
||||||
|
registerMemoryCli(program);
|
||||||
|
await program.parseAsync(["memory", "index", "--progress", "none"], { from: "user" });
|
||||||
|
|
||||||
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
|
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
|
||||||
expect(close).toHaveBeenCalled();
|
expect(close).toHaveBeenCalled();
|
||||||
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs close failures without failing the command", async () => {
|
it("logs close failures without failing the command", async () => {
|
||||||
@@ -291,7 +376,9 @@ describe("memory cli", () => {
|
|||||||
registerMemoryCli(program);
|
registerMemoryCli(program);
|
||||||
await program.parseAsync(["memory", "index"], { from: "user" });
|
await program.parseAsync(["memory", "index"], { from: "user" });
|
||||||
|
|
||||||
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
|
expect(sync).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
|
||||||
|
);
|
||||||
expect(close).toHaveBeenCalled();
|
expect(close).toHaveBeenCalled();
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(error).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("Memory manager close failed: close boom"),
|
expect.stringContaining("Memory manager close failed: close boom"),
|
||||||
|
|||||||
+277
-241
@@ -1,39 +1,36 @@
|
|||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import type { MemorySearchConfig } from "../config/types.tools.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { setVerbose } from "../globals.js";
|
import { setVerbose } from "../globals.js";
|
||||||
import { withProgress, withProgressTotals } from "./progress.js";
|
import { withProgress, withProgressTotals } from "./progress.js";
|
||||||
import { formatErrorMessage, withManager } from "./cli-utils.js";
|
import { formatErrorMessage, withManager } from "./cli-utils.js";
|
||||||
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
|
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
|
||||||
|
import {
|
||||||
|
resolveMemoryCacheState,
|
||||||
|
resolveMemoryFtsState,
|
||||||
|
resolveMemoryVectorState,
|
||||||
|
type Tone,
|
||||||
|
} from "../memory/status-format.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
import { resolveStateDir } from "../config/paths.js";
|
|
||||||
|
|
||||||
type MemoryCommandOptions = {
|
type MemoryCommandOptions = {
|
||||||
agent?: string;
|
agent?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
deep?: boolean;
|
deep?: boolean;
|
||||||
index?: boolean;
|
index?: boolean;
|
||||||
|
indexMode?: IndexMode;
|
||||||
|
progress?: ProgressMode;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MemoryManager = NonNullable<MemorySearchManagerResult["manager"]>;
|
type MemoryManager = NonNullable<MemorySearchManagerResult["manager"]>;
|
||||||
|
type IndexMode = "auto" | "batch" | "direct";
|
||||||
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
|
type ProgressMode = "auto" | "line" | "log" | "none";
|
||||||
if (source === "memory") {
|
|
||||||
return `memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`;
|
|
||||||
}
|
|
||||||
if (source === "sessions") {
|
|
||||||
const stateDir = resolveStateDir(process.env, os.homedir);
|
|
||||||
return `sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`;
|
|
||||||
}
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
|
function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
|
||||||
const trimmed = agent?.trim();
|
const trimmed = agent?.trim();
|
||||||
@@ -41,14 +38,66 @@ function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
|
|||||||
return resolveDefaultAgentId(cfg);
|
return resolveDefaultAgentId(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAgentIds(cfg: ReturnType<typeof loadConfig>, agent?: string): string[] {
|
function resolveIndexMode(raw?: string): IndexMode {
|
||||||
const trimmed = agent?.trim();
|
if (!raw) return "auto";
|
||||||
if (trimmed) return [trimmed];
|
const trimmed = raw.trim().toLowerCase();
|
||||||
const list = cfg.agents?.list ?? [];
|
if (trimmed === "batch") return "batch";
|
||||||
if (list.length > 0) {
|
if (trimmed === "direct") return "direct";
|
||||||
return list.map((entry) => entry.id).filter(Boolean);
|
return "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProgressMode(raw?: string): ProgressMode {
|
||||||
|
if (!raw) return "auto";
|
||||||
|
const trimmed = raw.trim().toLowerCase();
|
||||||
|
if (trimmed === "line") return "line";
|
||||||
|
if (trimmed === "log") return "log";
|
||||||
|
if (trimmed === "none") return "none";
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyIndexMode(cfg: ClawdbotConfig, agentId: string, mode: IndexMode): ClawdbotConfig {
|
||||||
|
if (mode === "auto") return cfg;
|
||||||
|
const enabled = mode === "batch";
|
||||||
|
const patchMemorySearch = (memorySearch?: MemorySearchConfig) => {
|
||||||
|
const remote = memorySearch?.remote;
|
||||||
|
const batch = remote?.batch;
|
||||||
|
return {
|
||||||
|
...memorySearch,
|
||||||
|
remote: {
|
||||||
|
...remote,
|
||||||
|
batch: {
|
||||||
|
...batch,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const nextAgents = { ...cfg.agents };
|
||||||
|
nextAgents.defaults = {
|
||||||
|
...cfg.agents?.defaults,
|
||||||
|
memorySearch: patchMemorySearch(cfg.agents?.defaults?.memorySearch),
|
||||||
|
};
|
||||||
|
if (cfg.agents?.list?.length) {
|
||||||
|
nextAgents.list = cfg.agents.list.map((agent) =>
|
||||||
|
agent.id === agentId
|
||||||
|
? {
|
||||||
|
...agent,
|
||||||
|
memorySearch: patchMemorySearch(agent.memorySearch),
|
||||||
|
}
|
||||||
|
: agent,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return [resolveDefaultAgentId(cfg)];
|
return { ...cfg, agents: nextAgents };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProgressOptions(
|
||||||
|
mode: ProgressMode,
|
||||||
|
verbose: boolean,
|
||||||
|
): { enabled?: boolean; fallback?: "spinner" | "line" | "log" | "none" } {
|
||||||
|
if (mode === "none") return { enabled: false, fallback: "none" };
|
||||||
|
if (mode === "line") return { fallback: "line" };
|
||||||
|
if (mode === "log") return { fallback: "log" };
|
||||||
|
return { fallback: verbose ? "line" : undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerMemoryCli(program: Command) {
|
export function registerMemoryCli(program: Command) {
|
||||||
@@ -68,194 +117,182 @@ export function registerMemoryCli(program: Command) {
|
|||||||
.option("--json", "Print JSON")
|
.option("--json", "Print JSON")
|
||||||
.option("--deep", "Probe embedding provider availability")
|
.option("--deep", "Probe embedding provider availability")
|
||||||
.option("--index", "Reindex if dirty (implies --deep)")
|
.option("--index", "Reindex if dirty (implies --deep)")
|
||||||
|
.option("--index-mode <mode>", "Index mode (auto|batch|direct) when indexing", "auto")
|
||||||
|
.option("--progress <mode>", "Progress output (auto|line|log|none)", "auto")
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
.action(async (opts: MemoryCommandOptions) => {
|
.action(async (opts: MemoryCommandOptions) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const cfg = loadConfig();
|
const rawCfg = loadConfig();
|
||||||
const agentIds = resolveAgentIds(cfg, opts.agent);
|
const agentId = resolveAgent(rawCfg, opts.agent);
|
||||||
const allResults: Array<{
|
const indexMode = resolveIndexMode(opts.indexMode);
|
||||||
agentId: string;
|
const progressMode = resolveProgressMode(opts.progress);
|
||||||
status: ReturnType<MemoryManager["status"]>;
|
const progressOptions = resolveProgressOptions(progressMode, Boolean(opts.verbose));
|
||||||
embeddingProbe?: Awaited<ReturnType<MemoryManager["probeEmbeddingAvailability"]>>;
|
const cfg = applyIndexMode(rawCfg, agentId, indexMode);
|
||||||
indexError?: string;
|
await withManager<MemoryManager>({
|
||||||
}> = [];
|
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
||||||
|
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
||||||
for (const agentId of agentIds) {
|
onCloseError: (err) =>
|
||||||
await withManager<MemoryManager>({
|
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
||||||
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
close: (manager) => manager.close(),
|
||||||
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
run: async (manager) => {
|
||||||
onCloseError: (err) =>
|
const deep = Boolean(opts.deep || opts.index);
|
||||||
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
let embeddingProbe:
|
||||||
close: (manager) => manager.close(),
|
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
|
||||||
run: async (manager) => {
|
| undefined;
|
||||||
const deep = Boolean(opts.deep || opts.index);
|
let indexError: string | undefined;
|
||||||
let embeddingProbe:
|
if (deep) {
|
||||||
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
|
await withProgress(
|
||||||
| undefined;
|
{ label: "Checking memory…", total: 2, ...progressOptions },
|
||||||
let indexError: string | undefined;
|
async (progress) => {
|
||||||
if (deep) {
|
|
||||||
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
|
|
||||||
progress.setLabel("Probing vector…");
|
progress.setLabel("Probing vector…");
|
||||||
await manager.probeVectorAvailability();
|
await manager.probeVectorAvailability();
|
||||||
progress.tick();
|
progress.tick();
|
||||||
progress.setLabel("Probing embeddings…");
|
progress.setLabel("Probing embeddings…");
|
||||||
embeddingProbe = await manager.probeEmbeddingAvailability();
|
embeddingProbe = await manager.probeEmbeddingAvailability();
|
||||||
progress.tick();
|
progress.tick();
|
||||||
});
|
},
|
||||||
if (opts.index) {
|
);
|
||||||
await withProgressTotals(
|
if (opts.index) {
|
||||||
{
|
await withProgressTotals(
|
||||||
label: "Indexing memory…",
|
{
|
||||||
total: 0,
|
label: "Indexing memory…",
|
||||||
fallback: opts.verbose ? "line" : undefined,
|
total: 0,
|
||||||
},
|
...progressOptions,
|
||||||
async (update, progress) => {
|
},
|
||||||
try {
|
async (update, progress) => {
|
||||||
await manager.sync({
|
try {
|
||||||
reason: "cli",
|
await manager.sync({
|
||||||
progress: (syncUpdate) => {
|
reason: "cli",
|
||||||
update({
|
progress: (syncUpdate) => {
|
||||||
completed: syncUpdate.completed,
|
update({
|
||||||
total: syncUpdate.total,
|
completed: syncUpdate.completed,
|
||||||
label: syncUpdate.label,
|
total: syncUpdate.total,
|
||||||
});
|
label: syncUpdate.label,
|
||||||
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
|
});
|
||||||
},
|
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
|
||||||
});
|
},
|
||||||
} catch (err) {
|
});
|
||||||
indexError = formatErrorMessage(err);
|
} catch (err) {
|
||||||
defaultRuntime.error(`Memory index failed: ${indexError}`);
|
indexError = formatErrorMessage(err);
|
||||||
process.exitCode = 1;
|
defaultRuntime.error(`Memory index failed: ${indexError}`);
|
||||||
}
|
process.exitCode = 1;
|
||||||
},
|
}
|
||||||
);
|
},
|
||||||
}
|
);
|
||||||
} else {
|
|
||||||
await manager.probeVectorAvailability();
|
|
||||||
}
|
}
|
||||||
const status = manager.status();
|
} else {
|
||||||
allResults.push({ agentId, status, embeddingProbe, indexError });
|
await manager.probeVectorAvailability();
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
defaultRuntime.log(JSON.stringify(allResults, null, 2));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rich = isRich();
|
|
||||||
const heading = (text: string) => colorize(rich, theme.heading, text);
|
|
||||||
const muted = (text: string) => colorize(rich, theme.muted, text);
|
|
||||||
const info = (text: string) => colorize(rich, theme.info, text);
|
|
||||||
const success = (text: string) => colorize(rich, theme.success, text);
|
|
||||||
const warn = (text: string) => colorize(rich, theme.warn, text);
|
|
||||||
const accent = (text: string) => colorize(rich, theme.accent, text);
|
|
||||||
const label = (text: string) => muted(`${text}:`);
|
|
||||||
|
|
||||||
for (const result of allResults) {
|
|
||||||
const { agentId, status, embeddingProbe, indexError } = result;
|
|
||||||
if (opts.index) {
|
|
||||||
const line = indexError
|
|
||||||
? `Memory index failed: ${indexError}`
|
|
||||||
: "Memory index complete.";
|
|
||||||
defaultRuntime.log(line);
|
|
||||||
}
|
|
||||||
const lines = [
|
|
||||||
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
|
|
||||||
`${label("Provider")} ${info(status.provider)} ${muted(
|
|
||||||
`(requested: ${status.requestedProvider})`,
|
|
||||||
)}`,
|
|
||||||
`${label("Model")} ${info(status.model)}`,
|
|
||||||
status.sources?.length
|
|
||||||
? `${label("Sources")} ${info(status.sources.join(", "))}`
|
|
||||||
: null,
|
|
||||||
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
|
|
||||||
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
|
||||||
`${label("Store")} ${info(status.dbPath)}`,
|
|
||||||
`${label("Workspace")} ${info(status.workspaceDir)}`,
|
|
||||||
].filter(Boolean) as string[];
|
|
||||||
if (embeddingProbe) {
|
|
||||||
const state = embeddingProbe.ok ? "ready" : "unavailable";
|
|
||||||
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
|
|
||||||
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
|
|
||||||
if (embeddingProbe.error) {
|
|
||||||
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
|
|
||||||
}
|
}
|
||||||
}
|
const status = manager.status();
|
||||||
if (status.sourceCounts?.length) {
|
if (opts.json) {
|
||||||
lines.push(label("By source"));
|
defaultRuntime.log(
|
||||||
for (const entry of status.sourceCounts) {
|
JSON.stringify(
|
||||||
const counts = `${entry.files} files · ${entry.chunks} chunks`;
|
{
|
||||||
lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`);
|
...status,
|
||||||
|
embeddings: embeddingProbe
|
||||||
|
? { ok: embeddingProbe.ok, error: embeddingProbe.error }
|
||||||
|
: undefined,
|
||||||
|
indexError,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
if (opts.index) {
|
||||||
if (status.fallback) {
|
const line = indexError
|
||||||
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
? `Memory index failed: ${indexError}`
|
||||||
}
|
: "Memory index complete.";
|
||||||
if (status.vector) {
|
defaultRuntime.log(line);
|
||||||
const vectorState = status.vector.enabled
|
|
||||||
? status.vector.available
|
|
||||||
? "ready"
|
|
||||||
: "unavailable"
|
|
||||||
: "disabled";
|
|
||||||
const vectorColor =
|
|
||||||
vectorState === "ready"
|
|
||||||
? theme.success
|
|
||||||
: vectorState === "unavailable"
|
|
||||||
? theme.warn
|
|
||||||
: theme.muted;
|
|
||||||
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`);
|
|
||||||
if (status.vector.dims) {
|
|
||||||
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
|
|
||||||
}
|
}
|
||||||
if (status.vector.extensionPath) {
|
const rich = isRich();
|
||||||
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
|
const heading = (text: string) => colorize(rich, theme.heading, text);
|
||||||
|
const muted = (text: string) => colorize(rich, theme.muted, text);
|
||||||
|
const info = (text: string) => colorize(rich, theme.info, text);
|
||||||
|
const success = (text: string) => colorize(rich, theme.success, text);
|
||||||
|
const warn = (text: string) => colorize(rich, theme.warn, text);
|
||||||
|
const accent = (text: string) => colorize(rich, theme.accent, text);
|
||||||
|
const label = (text: string) => muted(`${text}:`);
|
||||||
|
const colorForTone = (tone: Tone) =>
|
||||||
|
tone === "ok" ? theme.success : tone === "warn" ? theme.warn : theme.muted;
|
||||||
|
const lines = [
|
||||||
|
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
|
||||||
|
`${label("Provider")} ${info(status.provider)} ${muted(
|
||||||
|
`(requested: ${status.requestedProvider})`,
|
||||||
|
)}`,
|
||||||
|
`${label("Model")} ${info(status.model)}`,
|
||||||
|
status.sources?.length
|
||||||
|
? `${label("Sources")} ${info(status.sources.join(", "))}`
|
||||||
|
: null,
|
||||||
|
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
|
||||||
|
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
||||||
|
`${label("Store")} ${info(status.dbPath)}`,
|
||||||
|
`${label("Workspace")} ${info(status.workspaceDir)}`,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
if (embeddingProbe) {
|
||||||
|
const state = embeddingProbe.ok ? "ready" : "unavailable";
|
||||||
|
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
|
||||||
|
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
|
||||||
|
if (embeddingProbe.error) {
|
||||||
|
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (status.vector.loadError) {
|
if (status.sourceCounts?.length) {
|
||||||
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
|
lines.push(label("By source"));
|
||||||
|
for (const entry of status.sourceCounts) {
|
||||||
|
const counts = `${entry.files} files · ${entry.chunks} chunks`;
|
||||||
|
lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (status.fallback) {
|
||||||
if (status.fts) {
|
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
||||||
const ftsState = status.fts.enabled
|
|
||||||
? status.fts.available
|
|
||||||
? "ready"
|
|
||||||
: "unavailable"
|
|
||||||
: "disabled";
|
|
||||||
const ftsColor =
|
|
||||||
ftsState === "ready"
|
|
||||||
? theme.success
|
|
||||||
: ftsState === "unavailable"
|
|
||||||
? theme.warn
|
|
||||||
: theme.muted;
|
|
||||||
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState)}`);
|
|
||||||
if (status.fts.error) {
|
|
||||||
lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
|
|
||||||
}
|
}
|
||||||
}
|
if (status.vector) {
|
||||||
if (status.cache) {
|
const vectorState = resolveMemoryVectorState(status.vector);
|
||||||
const cacheState = status.cache.enabled ? "enabled" : "disabled";
|
const vectorColor = colorForTone(vectorState.tone);
|
||||||
const cacheColor = status.cache.enabled ? theme.success : theme.muted;
|
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState.state)}`);
|
||||||
const suffix =
|
if (status.vector.dims) {
|
||||||
status.cache.enabled && typeof status.cache.entries === "number"
|
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
|
||||||
? ` (${status.cache.entries} entries)`
|
}
|
||||||
: "";
|
if (status.vector.extensionPath) {
|
||||||
lines.push(
|
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
|
||||||
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`,
|
}
|
||||||
);
|
if (status.vector.loadError) {
|
||||||
if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
|
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
|
||||||
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
|
}
|
||||||
}
|
}
|
||||||
}
|
if (status.fts) {
|
||||||
if (status.fallback?.reason) {
|
const ftsState = resolveMemoryFtsState(status.fts);
|
||||||
lines.push(muted(status.fallback.reason));
|
const ftsColor = colorForTone(ftsState.tone);
|
||||||
}
|
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState.state)}`);
|
||||||
if (indexError) {
|
if (status.fts.error) {
|
||||||
lines.push(`${label("Index error")} ${warn(indexError)}`);
|
lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
|
||||||
}
|
}
|
||||||
defaultRuntime.log(lines.join("\n"));
|
}
|
||||||
if (agentIds.length > 1) defaultRuntime.log("");
|
if (status.cache) {
|
||||||
}
|
const cacheState = resolveMemoryCacheState(status.cache);
|
||||||
|
const cacheColor = colorForTone(cacheState.tone);
|
||||||
|
const suffix =
|
||||||
|
status.cache.enabled && typeof status.cache.entries === "number"
|
||||||
|
? ` (${status.cache.entries} entries)`
|
||||||
|
: "";
|
||||||
|
lines.push(
|
||||||
|
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState.state)}${suffix}`,
|
||||||
|
);
|
||||||
|
if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
|
||||||
|
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status.fallback?.reason) {
|
||||||
|
lines.push(muted(status.fallback.reason));
|
||||||
|
}
|
||||||
|
if (indexError) {
|
||||||
|
lines.push(`${label("Index error")} ${warn(indexError)}`);
|
||||||
|
}
|
||||||
|
defaultRuntime.log(lines.join("\n"));
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
memory
|
memory
|
||||||
@@ -263,57 +300,56 @@ export function registerMemoryCli(program: Command) {
|
|||||||
.description("Reindex memory files")
|
.description("Reindex memory files")
|
||||||
.option("--agent <id>", "Agent id (default: default agent)")
|
.option("--agent <id>", "Agent id (default: default agent)")
|
||||||
.option("--force", "Force full reindex", false)
|
.option("--force", "Force full reindex", false)
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--index-mode <mode>", "Index mode (auto|batch|direct) when indexing", "auto")
|
||||||
|
.option("--progress <mode>", "Progress output (auto|line|log|none)", "auto")
|
||||||
.action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
|
.action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
const rawCfg = loadConfig();
|
||||||
const cfg = loadConfig();
|
const agentId = resolveAgent(rawCfg, opts.agent);
|
||||||
const agentIds = resolveAgentIds(cfg, opts.agent);
|
const indexMode = resolveIndexMode(opts.indexMode);
|
||||||
for (const agentId of agentIds) {
|
const progressMode = resolveProgressMode(opts.progress);
|
||||||
await withManager<MemoryManager>({
|
const progressOptions = resolveProgressOptions(progressMode, Boolean(opts.verbose));
|
||||||
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
const cfg = applyIndexMode(rawCfg, agentId, indexMode);
|
||||||
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
await withManager<MemoryManager>({
|
||||||
onCloseError: (err) =>
|
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
||||||
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
||||||
close: (manager) => manager.close(),
|
onCloseError: (err) =>
|
||||||
run: async (manager) => {
|
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
||||||
try {
|
close: (manager) => manager.close(),
|
||||||
if (opts.verbose) {
|
run: async (manager) => {
|
||||||
const status = manager.status();
|
try {
|
||||||
const rich = isRich();
|
if (progressMode === "none") {
|
||||||
const heading = (text: string) => colorize(rich, theme.heading, text);
|
|
||||||
const muted = (text: string) => colorize(rich, theme.muted, text);
|
|
||||||
const info = (text: string) => colorize(rich, theme.info, text);
|
|
||||||
const warn = (text: string) => colorize(rich, theme.warn, text);
|
|
||||||
const label = (text: string) => muted(`${text}:`);
|
|
||||||
const sourceLabels = status.sources.map((source) =>
|
|
||||||
formatSourceLabel(source, status.workspaceDir, agentId),
|
|
||||||
);
|
|
||||||
const lines = [
|
|
||||||
`${heading("Memory Index")} ${muted(`(${agentId})`)}`,
|
|
||||||
`${label("Provider")} ${info(status.provider)} ${muted(
|
|
||||||
`(requested: ${status.requestedProvider})`,
|
|
||||||
)}`,
|
|
||||||
`${label("Model")} ${info(status.model)}`,
|
|
||||||
sourceLabels.length
|
|
||||||
? `${label("Sources")} ${info(sourceLabels.join(", "))}`
|
|
||||||
: null,
|
|
||||||
].filter(Boolean) as string[];
|
|
||||||
if (status.fallback) {
|
|
||||||
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
|
||||||
}
|
|
||||||
defaultRuntime.log(lines.join("\n"));
|
|
||||||
defaultRuntime.log("");
|
|
||||||
}
|
|
||||||
await manager.sync({ reason: "cli", force: opts.force });
|
await manager.sync({ reason: "cli", force: opts.force });
|
||||||
defaultRuntime.log(`Memory index updated (${agentId}).`);
|
} else {
|
||||||
} catch (err) {
|
await withProgressTotals(
|
||||||
const message = formatErrorMessage(err);
|
{
|
||||||
defaultRuntime.error(`Memory index failed (${agentId}): ${message}`);
|
label: "Indexing memory…",
|
||||||
process.exitCode = 1;
|
total: 0,
|
||||||
|
...progressOptions,
|
||||||
|
},
|
||||||
|
async (update, progress) => {
|
||||||
|
await manager.sync({
|
||||||
|
reason: "cli",
|
||||||
|
force: opts.force,
|
||||||
|
progress: (syncUpdate) => {
|
||||||
|
update({
|
||||||
|
completed: syncUpdate.completed,
|
||||||
|
total: syncUpdate.total,
|
||||||
|
label: syncUpdate.label,
|
||||||
|
});
|
||||||
|
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
defaultRuntime.log("Memory index updated.");
|
||||||
});
|
} catch (err) {
|
||||||
}
|
const message = formatErrorMessage(err);
|
||||||
|
defaultRuntime.error(`Memory index failed: ${message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
memory
|
memory
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ type NodeDaemonStatusOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function renderNodeServiceStartHints(): string[] {
|
function renderNodeServiceStartHints(): string[] {
|
||||||
const base = ["clawdbot node service install", "clawdbot node start"];
|
const base = ["clawdbot node daemon install", "clawdbot node start"];
|
||||||
switch (process.platform) {
|
switch (process.platform) {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return [
|
return [
|
||||||
@@ -133,7 +133,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (resolveIsNixMode(process.env)) {
|
if (resolveIsNixMode(process.env)) {
|
||||||
fail("Nix mode detected; service install is disabled.");
|
fail("Nix mode detected; daemon install is disabled.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
|
|||||||
});
|
});
|
||||||
if (!json) {
|
if (!json) {
|
||||||
defaultRuntime.log(`Node service already ${service.loadedText}.`);
|
defaultRuntime.log(`Node service already ${service.loadedText}.`);
|
||||||
defaultRuntime.log("Reinstall with: clawdbot node service install --force");
|
defaultRuntime.log("Reinstall with: clawdbot node daemon install --force");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ export async function runNodeDaemonUninstall(opts: NodeDaemonLifecycleOptions =
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (resolveIsNixMode(process.env)) {
|
if (resolveIsNixMode(process.env)) {
|
||||||
fail("Nix mode detected; service uninstall is disabled.");
|
fail("Nix mode detected; daemon uninstall is disabled.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,71 +51,63 @@ export function registerNodeCli(program: Command) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const registerNodeServiceCommands = (cmd: Command) => {
|
|
||||||
cmd
|
|
||||||
.command("status")
|
|
||||||
.description("Show node service status")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonStatus(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command("install")
|
|
||||||
.description("Install the node service (launchd/systemd/schtasks)")
|
|
||||||
.option("--host <host>", "Gateway bridge host")
|
|
||||||
.option("--port <port>", "Gateway bridge port")
|
|
||||||
.option("--tls", "Use TLS for the bridge connection", false)
|
|
||||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
|
||||||
.option("--node-id <id>", "Override node id (clears pairing token)")
|
|
||||||
.option("--display-name <name>", "Override node display name")
|
|
||||||
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
|
|
||||||
.option("--force", "Reinstall/overwrite if already installed", false)
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonInstall(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command("uninstall")
|
|
||||||
.description("Uninstall the node service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonUninstall(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command("start")
|
|
||||||
.description("Start the node service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonStart(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command("stop")
|
|
||||||
.description("Stop the node service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonStop(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command("restart")
|
|
||||||
.description("Restart the node service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonRestart(opts);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const service = node
|
|
||||||
.command("service")
|
|
||||||
.description("Manage the headless node service (launchd/systemd/schtasks)");
|
|
||||||
registerNodeServiceCommands(service);
|
|
||||||
|
|
||||||
const daemon = node
|
const daemon = node
|
||||||
.command("daemon", { hidden: true })
|
.command("daemon")
|
||||||
.description("Legacy alias for node service commands");
|
.description("Manage the headless node daemon service (launchd/systemd/schtasks)");
|
||||||
registerNodeServiceCommands(daemon);
|
|
||||||
|
daemon
|
||||||
|
.command("status")
|
||||||
|
.description("Show node daemon status")
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonStatus(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command("install")
|
||||||
|
.description("Install the node daemon service (launchd/systemd/schtasks)")
|
||||||
|
.option("--host <host>", "Gateway bridge host")
|
||||||
|
.option("--port <port>", "Gateway bridge port")
|
||||||
|
.option("--tls", "Use TLS for the bridge connection", false)
|
||||||
|
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
||||||
|
.option("--node-id <id>", "Override node id (clears pairing token)")
|
||||||
|
.option("--display-name <name>", "Override node display name")
|
||||||
|
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
|
||||||
|
.option("--force", "Reinstall/overwrite if already installed", false)
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonInstall(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command("uninstall")
|
||||||
|
.description("Uninstall the node daemon service (launchd/systemd/schtasks)")
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonUninstall(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command("start")
|
||||||
|
.description("Start the node daemon service (launchd/systemd/schtasks)")
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonStart(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command("stop")
|
||||||
|
.description("Stop the node daemon service (launchd/systemd/schtasks)")
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonStop(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command("restart")
|
||||||
|
.description("Restart the node daemon service (launchd/systemd/schtasks)")
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runNodeDaemonRestart(opts);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -4,43 +4,6 @@ import { formatAge, formatPermissions, parseNodeList, parsePairingList } from ".
|
|||||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||||
import type { NodesRpcOpts } from "./types.js";
|
import type { NodesRpcOpts } from "./types.js";
|
||||||
|
|
||||||
function formatVersionLabel(raw: string) {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) return raw;
|
|
||||||
if (trimmed.toLowerCase().startsWith("v")) return trimmed;
|
|
||||||
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveNodeVersions(node: {
|
|
||||||
platform?: string;
|
|
||||||
version?: string;
|
|
||||||
coreVersion?: string;
|
|
||||||
uiVersion?: string;
|
|
||||||
}) {
|
|
||||||
const core = node.coreVersion?.trim() || undefined;
|
|
||||||
const ui = node.uiVersion?.trim() || undefined;
|
|
||||||
if (core || ui) return { core, ui };
|
|
||||||
const legacy = node.version?.trim();
|
|
||||||
if (!legacy) return { core: undefined, ui: undefined };
|
|
||||||
const platform = node.platform?.trim().toLowerCase() ?? "";
|
|
||||||
const headless =
|
|
||||||
platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows";
|
|
||||||
return headless ? { core: legacy, ui: undefined } : { core: undefined, ui: legacy };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNodeVersions(node: {
|
|
||||||
platform?: string;
|
|
||||||
version?: string;
|
|
||||||
coreVersion?: string;
|
|
||||||
uiVersion?: string;
|
|
||||||
}) {
|
|
||||||
const { core, ui } = resolveNodeVersions(node);
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (core) parts.push(`core ${formatVersionLabel(core)}`);
|
|
||||||
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
|
|
||||||
return parts.length > 0 ? parts.join(" · ") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerNodesStatusCommands(nodes: Command) {
|
export function registerNodesStatusCommands(nodes: Command) {
|
||||||
nodesCallOpts(
|
nodesCallOpts(
|
||||||
nodes
|
nodes
|
||||||
@@ -66,8 +29,6 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
|
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
|
||||||
const perms = formatPermissions(n.permissions);
|
const perms = formatPermissions(n.permissions);
|
||||||
const permsText = perms ? ` · perms: ${perms}` : "";
|
const permsText = perms ? ` · perms: ${perms}` : "";
|
||||||
const versions = formatNodeVersions(n);
|
|
||||||
const versionText = versions ? ` · ${versions}` : "";
|
|
||||||
const caps =
|
const caps =
|
||||||
Array.isArray(n.caps) && n.caps.length > 0
|
Array.isArray(n.caps) && n.caps.length > 0
|
||||||
? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]`
|
? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]`
|
||||||
@@ -76,7 +37,7 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
: "?";
|
: "?";
|
||||||
const pairing = n.paired ? "paired" : "unpaired";
|
const pairing = n.paired ? "paired" : "unpaired";
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText}${versionText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
|
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -116,19 +77,12 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
|
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
|
||||||
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
|
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
|
||||||
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
|
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
|
||||||
const versions = formatNodeVersions(obj as {
|
|
||||||
platform?: string;
|
|
||||||
version?: string;
|
|
||||||
coreVersion?: string;
|
|
||||||
uiVersion?: string;
|
|
||||||
});
|
|
||||||
|
|
||||||
const parts: string[] = ["Node:", displayName, nodeId];
|
const parts: string[] = ["Node:", displayName, nodeId];
|
||||||
if (ip) parts.push(ip);
|
if (ip) parts.push(ip);
|
||||||
if (family) parts.push(`device: ${family}`);
|
if (family) parts.push(`device: ${family}`);
|
||||||
if (model) parts.push(`hw: ${model}`);
|
if (model) parts.push(`hw: ${model}`);
|
||||||
if (perms) parts.push(`perms: ${perms}`);
|
if (perms) parts.push(`perms: ${perms}`);
|
||||||
if (versions) parts.push(versions);
|
|
||||||
parts.push(connected ? "connected" : "disconnected");
|
parts.push(connected ? "connected" : "disconnected");
|
||||||
parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`);
|
parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`);
|
||||||
defaultRuntime.log(parts.join(" · "));
|
defaultRuntime.log(parts.join(" · "));
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ export type NodeListNode = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
coreVersion?: string;
|
|
||||||
uiVersion?: string;
|
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
deviceFamily?: string;
|
deviceFamily?: string;
|
||||||
modelIdentifier?: string;
|
modelIdentifier?: string;
|
||||||
@@ -64,8 +62,6 @@ export type PendingRequest = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
coreVersion?: string;
|
|
||||||
uiVersion?: string;
|
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
isRepair?: boolean;
|
isRepair?: boolean;
|
||||||
ts: number;
|
ts: number;
|
||||||
@@ -77,8 +73,6 @@ export type PairedNode = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
coreVersion?: string;
|
|
||||||
uiVersion?: string;
|
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
|
|||||||
+1
-39
@@ -13,7 +13,6 @@ import {
|
|||||||
resolvePluginInstallDir,
|
resolvePluginInstallDir,
|
||||||
} from "../plugins/install.js";
|
} from "../plugins/install.js";
|
||||||
import { recordPluginInstall } from "../plugins/installs.js";
|
import { recordPluginInstall } from "../plugins/installs.js";
|
||||||
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
|
||||||
import type { PluginRecord } from "../plugins/registry.js";
|
import type { PluginRecord } from "../plugins/registry.js";
|
||||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
import { buildPluginStatusReport } from "../plugins/status.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -80,31 +79,6 @@ async function readInstalledPackageVersion(dir: string): Promise<string | undefi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySlotSelectionForPlugin(
|
|
||||||
config: ClawdbotConfig,
|
|
||||||
pluginId: string,
|
|
||||||
): { config: ClawdbotConfig; warnings: string[] } {
|
|
||||||
const report = buildPluginStatusReport({ config });
|
|
||||||
const plugin = report.plugins.find((entry) => entry.id === pluginId);
|
|
||||||
if (!plugin) {
|
|
||||||
return { config, warnings: [] };
|
|
||||||
}
|
|
||||||
const result = applyExclusiveSlotSelection({
|
|
||||||
config,
|
|
||||||
selectedId: plugin.id,
|
|
||||||
selectedKind: plugin.kind,
|
|
||||||
registry: report,
|
|
||||||
});
|
|
||||||
return { config: result.config, warnings: result.warnings };
|
|
||||||
}
|
|
||||||
|
|
||||||
function logSlotWarnings(warnings: string[]) {
|
|
||||||
if (warnings.length === 0) return;
|
|
||||||
for (const warning of warnings) {
|
|
||||||
defaultRuntime.log(chalk.yellow(warning));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerPluginsCli(program: Command) {
|
export function registerPluginsCli(program: Command) {
|
||||||
const plugins = program
|
const plugins = program
|
||||||
.command("plugins")
|
.command("plugins")
|
||||||
@@ -223,7 +197,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
.argument("<id>", "Plugin id")
|
.argument("<id>", "Plugin id")
|
||||||
.action(async (id: string) => {
|
.action(async (id: string) => {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
let next: ClawdbotConfig = {
|
const next = {
|
||||||
...cfg,
|
...cfg,
|
||||||
plugins: {
|
plugins: {
|
||||||
...cfg.plugins,
|
...cfg.plugins,
|
||||||
@@ -236,10 +210,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const slotResult = applySlotSelectionForPlugin(next, id);
|
|
||||||
next = slotResult.config;
|
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
logSlotWarnings(slotResult.warnings);
|
|
||||||
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
|
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,10 +280,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: resolved,
|
installPath: resolved,
|
||||||
version: probe.version,
|
version: probe.version,
|
||||||
});
|
});
|
||||||
const slotResult = applySlotSelectionForPlugin(next, probe.pluginId);
|
|
||||||
next = slotResult.config;
|
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
logSlotWarnings(slotResult.warnings);
|
|
||||||
defaultRuntime.log(`Linked plugin path: ${resolved}`);
|
defaultRuntime.log(`Linked plugin path: ${resolved}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
return;
|
return;
|
||||||
@@ -351,10 +319,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: result.targetDir,
|
installPath: result.targetDir,
|
||||||
version: result.version,
|
version: result.version,
|
||||||
});
|
});
|
||||||
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
|
||||||
next = slotResult.config;
|
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
logSlotWarnings(slotResult.warnings);
|
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
return;
|
return;
|
||||||
@@ -414,10 +379,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: result.targetDir,
|
installPath: result.targetDir,
|
||||||
version: result.version,
|
version: result.version,
|
||||||
});
|
});
|
||||||
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
|
||||||
next = slotResult.config;
|
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
logSlotWarnings(slotResult.warnings);
|
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { registerDaemonCli } from "../daemon-cli.js";
|
|||||||
import { registerDnsCli } from "../dns-cli.js";
|
import { registerDnsCli } from "../dns-cli.js";
|
||||||
import { registerDirectoryCli } from "../directory-cli.js";
|
import { registerDirectoryCli } from "../directory-cli.js";
|
||||||
import { registerDocsCli } from "../docs-cli.js";
|
import { registerDocsCli } from "../docs-cli.js";
|
||||||
import { registerExecApprovalsCli } from "../exec-approvals-cli.js";
|
|
||||||
import { registerGatewayCli } from "../gateway-cli.js";
|
import { registerGatewayCli } from "../gateway-cli.js";
|
||||||
import { registerHooksCli } from "../hooks-cli.js";
|
import { registerHooksCli } from "../hooks-cli.js";
|
||||||
import { registerWebhooksCli } from "../webhooks-cli.js";
|
import { registerWebhooksCli } from "../webhooks-cli.js";
|
||||||
@@ -20,7 +19,6 @@ import { registerPairingCli } from "../pairing-cli.js";
|
|||||||
import { registerPluginsCli } from "../plugins-cli.js";
|
import { registerPluginsCli } from "../plugins-cli.js";
|
||||||
import { registerSandboxCli } from "../sandbox-cli.js";
|
import { registerSandboxCli } from "../sandbox-cli.js";
|
||||||
import { registerSecurityCli } from "../security-cli.js";
|
import { registerSecurityCli } from "../security-cli.js";
|
||||||
import { registerServiceCli } from "../service-cli.js";
|
|
||||||
import { registerSkillsCli } from "../skills-cli.js";
|
import { registerSkillsCli } from "../skills-cli.js";
|
||||||
import { registerTuiCli } from "../tui-cli.js";
|
import { registerTuiCli } from "../tui-cli.js";
|
||||||
import { registerUpdateCli } from "../update-cli.js";
|
import { registerUpdateCli } from "../update-cli.js";
|
||||||
@@ -29,10 +27,8 @@ export function registerSubCliCommands(program: Command) {
|
|||||||
registerAcpCli(program);
|
registerAcpCli(program);
|
||||||
registerDaemonCli(program);
|
registerDaemonCli(program);
|
||||||
registerGatewayCli(program);
|
registerGatewayCli(program);
|
||||||
registerServiceCli(program);
|
|
||||||
registerLogsCli(program);
|
registerLogsCli(program);
|
||||||
registerModelsCli(program);
|
registerModelsCli(program);
|
||||||
registerExecApprovalsCli(program);
|
|
||||||
registerNodesCli(program);
|
registerNodesCli(program);
|
||||||
registerNodeCli(program);
|
registerNodeCli(program);
|
||||||
registerSandboxCli(program);
|
registerSandboxCli(program);
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Command } from "commander";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const runDaemonStatus = vi.fn(async () => {});
|
|
||||||
const runNodeDaemonStatus = vi.fn(async () => {});
|
|
||||||
|
|
||||||
vi.mock("./daemon-cli/runners.js", () => ({
|
|
||||||
runDaemonInstall: vi.fn(async () => {}),
|
|
||||||
runDaemonRestart: vi.fn(async () => {}),
|
|
||||||
runDaemonStart: vi.fn(async () => {}),
|
|
||||||
runDaemonStatus: (opts: unknown) => runDaemonStatus(opts),
|
|
||||||
runDaemonStop: vi.fn(async () => {}),
|
|
||||||
runDaemonUninstall: vi.fn(async () => {}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./node-cli/daemon.js", () => ({
|
|
||||||
runNodeDaemonInstall: vi.fn(async () => {}),
|
|
||||||
runNodeDaemonRestart: vi.fn(async () => {}),
|
|
||||||
runNodeDaemonStart: vi.fn(async () => {}),
|
|
||||||
runNodeDaemonStatus: (opts: unknown) => runNodeDaemonStatus(opts),
|
|
||||||
runNodeDaemonStop: vi.fn(async () => {}),
|
|
||||||
runNodeDaemonUninstall: vi.fn(async () => {}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./deps.js", () => ({
|
|
||||||
createDefaultDeps: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("service CLI coverage", () => {
|
|
||||||
it("routes service gateway status to daemon status", async () => {
|
|
||||||
runDaemonStatus.mockClear();
|
|
||||||
runNodeDaemonStatus.mockClear();
|
|
||||||
|
|
||||||
const { registerServiceCli } = await import("./service-cli.js");
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerServiceCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["service", "gateway", "status"], { from: "user" });
|
|
||||||
|
|
||||||
expect(runDaemonStatus).toHaveBeenCalledTimes(1);
|
|
||||||
expect(runNodeDaemonStatus).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes service node status to node daemon status", async () => {
|
|
||||||
runDaemonStatus.mockClear();
|
|
||||||
runNodeDaemonStatus.mockClear();
|
|
||||||
|
|
||||||
const { registerServiceCli } = await import("./service-cli.js");
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerServiceCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["service", "node", "status"], { from: "user" });
|
|
||||||
|
|
||||||
expect(runNodeDaemonStatus).toHaveBeenCalledTimes(1);
|
|
||||||
expect(runDaemonStatus).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import type { Command } from "commander";
|
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
|
||||||
import { theme } from "../terminal/theme.js";
|
|
||||||
import { createDefaultDeps } from "./deps.js";
|
|
||||||
import {
|
|
||||||
runDaemonInstall,
|
|
||||||
runDaemonRestart,
|
|
||||||
runDaemonStart,
|
|
||||||
runDaemonStatus,
|
|
||||||
runDaemonStop,
|
|
||||||
runDaemonUninstall,
|
|
||||||
} from "./daemon-cli/runners.js";
|
|
||||||
import {
|
|
||||||
runNodeDaemonInstall,
|
|
||||||
runNodeDaemonRestart,
|
|
||||||
runNodeDaemonStart,
|
|
||||||
runNodeDaemonStatus,
|
|
||||||
runNodeDaemonStop,
|
|
||||||
runNodeDaemonUninstall,
|
|
||||||
} from "./node-cli/daemon.js";
|
|
||||||
|
|
||||||
export function registerServiceCli(program: Command) {
|
|
||||||
const service = program
|
|
||||||
.command("service")
|
|
||||||
.description("Manage Gateway and node host services (launchd/systemd/schtasks)")
|
|
||||||
.addHelpText(
|
|
||||||
"after",
|
|
||||||
() =>
|
|
||||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const gateway = service.command("gateway").description("Manage the Gateway service");
|
|
||||||
|
|
||||||
gateway
|
|
||||||
.command("status")
|
|
||||||
.description("Show gateway service status + probe the Gateway")
|
|
||||||
.option("--url <url>", "Gateway WebSocket URL (defaults to config/remote/local)")
|
|
||||||
.option("--token <token>", "Gateway token (if required)")
|
|
||||||
.option("--password <password>", "Gateway password (password auth)")
|
|
||||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
|
||||||
.option("--no-probe", "Skip RPC probe")
|
|
||||||
.option("--deep", "Scan system-level services", false)
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runDaemonStatus({
|
|
||||||
rpc: opts,
|
|
||||||
probe: Boolean(opts.probe),
|
|
||||||
deep: Boolean(opts.deep),
|
|
||||||
json: Boolean(opts.json),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
gateway
|
|
||||||
.command("install")
|
|
||||||
.description("Install the Gateway service (launchd/systemd/schtasks)")
|
|
||||||
.option("--port <port>", "Gateway port")
|
|
||||||
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
|
|
||||||
.option("--token <token>", "Gateway token (token auth)")
|
|
||||||
.option("--force", "Reinstall/overwrite if already installed", false)
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runDaemonInstall(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
gateway
|
|
||||||
.command("uninstall")
|
|
||||||
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runDaemonUninstall(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
gateway
|
|
||||||
.command("start")
|
|
||||||
.description("Start the Gateway service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runDaemonStart(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
gateway
|
|
||||||
.command("stop")
|
|
||||||
.description("Stop the Gateway service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runDaemonStop(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
gateway
|
|
||||||
.command("restart")
|
|
||||||
.description("Restart the Gateway service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runDaemonRestart(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
const node = service.command("node").description("Manage the node host service");
|
|
||||||
|
|
||||||
node
|
|
||||||
.command("status")
|
|
||||||
.description("Show node host service status")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonStatus(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
node
|
|
||||||
.command("install")
|
|
||||||
.description("Install the node host service (launchd/systemd/schtasks)")
|
|
||||||
.option("--host <host>", "Gateway bridge host")
|
|
||||||
.option("--port <port>", "Gateway bridge port")
|
|
||||||
.option("--tls", "Use TLS for the bridge connection", false)
|
|
||||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
|
||||||
.option("--node-id <id>", "Override node id (clears pairing token)")
|
|
||||||
.option("--display-name <name>", "Override node display name")
|
|
||||||
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
|
|
||||||
.option("--force", "Reinstall/overwrite if already installed", false)
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonInstall(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
node
|
|
||||||
.command("uninstall")
|
|
||||||
.description("Uninstall the node host service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonUninstall(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
node
|
|
||||||
.command("start")
|
|
||||||
.description("Start the node host service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonStart(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
node
|
|
||||||
.command("stop")
|
|
||||||
.description("Stop the node host service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonStop(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
node
|
|
||||||
.command("restart")
|
|
||||||
.description("Restart the node host service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonRestart(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build default deps (parity with daemon CLI).
|
|
||||||
void createDefaultDeps();
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ export type AuthChoiceGroupId =
|
|||||||
| "openai"
|
| "openai"
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "google"
|
| "google"
|
||||||
| "copilot"
|
|
||||||
| "openrouter"
|
| "openrouter"
|
||||||
| "ai-gateway"
|
| "ai-gateway"
|
||||||
| "moonshot"
|
| "moonshot"
|
||||||
@@ -69,14 +68,8 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
|||||||
{
|
{
|
||||||
value: "google",
|
value: "google",
|
||||||
label: "Google",
|
label: "Google",
|
||||||
hint: "Gemini API key + OAuth",
|
hint: "Gemini API key",
|
||||||
choices: ["gemini-api-key", "google-antigravity", "google-gemini-cli"],
|
choices: ["gemini-api-key"],
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "copilot",
|
|
||||||
label: "Copilot",
|
|
||||||
hint: "GitHub + local proxy",
|
|
||||||
choices: ["github-copilot", "copilot-proxy"],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "openrouter",
|
value: "openrouter",
|
||||||
@@ -202,23 +195,8 @@ export function buildAuthChoiceOptions(params: {
|
|||||||
hint: "Uses GitHub device flow",
|
hint: "Uses GitHub device flow",
|
||||||
});
|
});
|
||||||
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
|
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
|
||||||
options.push({
|
|
||||||
value: "google-antigravity",
|
|
||||||
label: "Google Antigravity OAuth",
|
|
||||||
hint: "Uses the bundled Antigravity auth plugin",
|
|
||||||
});
|
|
||||||
options.push({
|
|
||||||
value: "google-gemini-cli",
|
|
||||||
label: "Google Gemini CLI OAuth",
|
|
||||||
hint: "Uses the bundled Gemini CLI auth plugin",
|
|
||||||
});
|
|
||||||
options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" });
|
options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" });
|
||||||
options.push({ value: "qwen-portal", label: "Qwen OAuth" });
|
options.push({ value: "qwen-portal", label: "Qwen OAuth" });
|
||||||
options.push({
|
|
||||||
value: "copilot-proxy",
|
|
||||||
label: "Copilot Proxy (local)",
|
|
||||||
hint: "Local proxy for VS Code Copilot models",
|
|
||||||
});
|
|
||||||
options.push({ value: "apiKey", label: "Anthropic API key" });
|
options.push({ value: "apiKey", label: "Anthropic API key" });
|
||||||
// Token flow is currently Anthropic-only; use CLI for advanced providers.
|
// Token flow is currently Anthropic-only; use CLI for advanced providers.
|
||||||
options.push({
|
options.push({
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
|
||||||
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
|
|
||||||
|
|
||||||
export async function applyAuthChoiceCopilotProxy(
|
|
||||||
params: ApplyAuthChoiceParams,
|
|
||||||
): Promise<ApplyAuthChoiceResult | null> {
|
|
||||||
return await applyAuthChoicePluginProvider(params, {
|
|
||||||
authChoice: "copilot-proxy",
|
|
||||||
pluginId: "copilot-proxy",
|
|
||||||
providerId: "copilot-proxy",
|
|
||||||
methodId: "local",
|
|
||||||
label: "Copilot Proxy",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
|
||||||
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
|
|
||||||
|
|
||||||
export async function applyAuthChoiceGoogleAntigravity(
|
|
||||||
params: ApplyAuthChoiceParams,
|
|
||||||
): Promise<ApplyAuthChoiceResult | null> {
|
|
||||||
return await applyAuthChoicePluginProvider(params, {
|
|
||||||
authChoice: "google-antigravity",
|
|
||||||
pluginId: "google-antigravity-auth",
|
|
||||||
providerId: "google-antigravity",
|
|
||||||
methodId: "oauth",
|
|
||||||
label: "Google Antigravity",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
|
||||||
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
|
|
||||||
|
|
||||||
export async function applyAuthChoiceGoogleGeminiCli(
|
|
||||||
params: ApplyAuthChoiceParams,
|
|
||||||
): Promise<ApplyAuthChoiceResult | null> {
|
|
||||||
return await applyAuthChoicePluginProvider(params, {
|
|
||||||
authChoice: "google-gemini-cli",
|
|
||||||
pluginId: "google-gemini-cli-auth",
|
|
||||||
providerId: "google-gemini-cli",
|
|
||||||
methodId: "oauth",
|
|
||||||
label: "Google Gemini CLI",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
|
|
||||||
import {
|
|
||||||
resolveDefaultAgentId,
|
|
||||||
resolveAgentDir,
|
|
||||||
resolveAgentWorkspaceDir,
|
|
||||||
} from "../agents/agent-scope.js";
|
|
||||||
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
|
||||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
|
||||||
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
|
||||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
|
||||||
import { resolvePluginProviders } from "../plugins/providers.js";
|
|
||||||
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
|
|
||||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
|
||||||
import { applyAuthProfileConfig } from "./onboard-auth.js";
|
|
||||||
import { openUrl } from "./onboard-helpers.js";
|
|
||||||
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
|
|
||||||
import { isRemoteEnvironment } from "./oauth-env.js";
|
|
||||||
|
|
||||||
export type PluginProviderAuthChoiceOptions = {
|
|
||||||
authChoice: string;
|
|
||||||
pluginId: string;
|
|
||||||
providerId: string;
|
|
||||||
methodId?: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveProviderMatch(
|
|
||||||
providers: ProviderPlugin[],
|
|
||||||
rawProvider: string,
|
|
||||||
): ProviderPlugin | null {
|
|
||||||
const normalized = normalizeProviderId(rawProvider);
|
|
||||||
return (
|
|
||||||
providers.find((provider) => normalizeProviderId(provider.id) === normalized) ??
|
|
||||||
providers.find(
|
|
||||||
(provider) =>
|
|
||||||
provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false,
|
|
||||||
) ??
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null {
|
|
||||||
const raw = rawMethod?.trim();
|
|
||||||
if (!raw) return null;
|
|
||||||
const normalized = raw.toLowerCase();
|
|
||||||
return (
|
|
||||||
provider.auth.find((method) => method.id.toLowerCase() === normalized) ??
|
|
||||||
provider.auth.find((method) => method.label.toLowerCase() === normalized) ??
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeConfigPatch<T>(base: T, patch: unknown): T {
|
|
||||||
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
|
|
||||||
return patch as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next: Record<string, unknown> = { ...base };
|
|
||||||
for (const [key, value] of Object.entries(patch)) {
|
|
||||||
const existing = next[key];
|
|
||||||
if (isPlainRecord(existing) && isPlainRecord(value)) {
|
|
||||||
next[key] = mergeConfigPatch(existing, value);
|
|
||||||
} else {
|
|
||||||
next[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
|
|
||||||
const models = { ...cfg.agents?.defaults?.models };
|
|
||||||
models[model] = models[model] ?? {};
|
|
||||||
|
|
||||||
const existingModel = cfg.agents?.defaults?.model;
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
agents: {
|
|
||||||
...cfg.agents,
|
|
||||||
defaults: {
|
|
||||||
...cfg.agents?.defaults,
|
|
||||||
models,
|
|
||||||
model: {
|
|
||||||
...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel
|
|
||||||
? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks }
|
|
||||||
: undefined),
|
|
||||||
primary: model,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyAuthChoicePluginProvider(
|
|
||||||
params: ApplyAuthChoiceParams,
|
|
||||||
options: PluginProviderAuthChoiceOptions,
|
|
||||||
): Promise<ApplyAuthChoiceResult | null> {
|
|
||||||
if (params.authChoice !== options.authChoice) return null;
|
|
||||||
|
|
||||||
const enableResult = enablePluginInConfig(params.config, options.pluginId);
|
|
||||||
let nextConfig = enableResult.config;
|
|
||||||
if (!enableResult.enabled) {
|
|
||||||
await params.prompter.note(
|
|
||||||
`${options.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`,
|
|
||||||
options.label,
|
|
||||||
);
|
|
||||||
return { config: nextConfig };
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig);
|
|
||||||
const defaultAgentId = resolveDefaultAgentId(nextConfig);
|
|
||||||
const agentDir =
|
|
||||||
params.agentDir ??
|
|
||||||
(agentId === defaultAgentId ? resolveClawdbotAgentDir() : resolveAgentDir(nextConfig, agentId));
|
|
||||||
const workspaceDir =
|
|
||||||
resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir();
|
|
||||||
|
|
||||||
const providers = resolvePluginProviders({ config: nextConfig, workspaceDir });
|
|
||||||
const provider = resolveProviderMatch(providers, options.providerId);
|
|
||||||
if (!provider) {
|
|
||||||
await params.prompter.note(
|
|
||||||
`${options.label} auth plugin is not available. Enable it and re-run the wizard.`,
|
|
||||||
options.label,
|
|
||||||
);
|
|
||||||
return { config: nextConfig };
|
|
||||||
}
|
|
||||||
|
|
||||||
const method = pickAuthMethod(provider, options.methodId) ?? provider.auth[0];
|
|
||||||
if (!method) {
|
|
||||||
await params.prompter.note(`${options.label} auth method missing.`, options.label);
|
|
||||||
return { config: nextConfig };
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRemote = isRemoteEnvironment();
|
|
||||||
const result = await method.run({
|
|
||||||
config: nextConfig,
|
|
||||||
agentDir,
|
|
||||||
workspaceDir,
|
|
||||||
prompter: params.prompter,
|
|
||||||
runtime: params.runtime,
|
|
||||||
isRemote,
|
|
||||||
openUrl: async (url) => {
|
|
||||||
await openUrl(url);
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.configPatch) {
|
|
||||||
nextConfig = mergeConfigPatch(nextConfig, result.configPatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const profile of result.profiles) {
|
|
||||||
upsertAuthProfile({
|
|
||||||
profileId: profile.profileId,
|
|
||||||
credential: profile.credential,
|
|
||||||
agentDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
|
||||||
profileId: profile.profileId,
|
|
||||||
provider: profile.credential.provider,
|
|
||||||
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
|
|
||||||
...("email" in profile.credential && profile.credential.email
|
|
||||||
? { email: profile.credential.email }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let agentModelOverride: string | undefined;
|
|
||||||
if (result.defaultModel) {
|
|
||||||
if (params.setDefaultModel) {
|
|
||||||
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
|
|
||||||
await params.prompter.note(
|
|
||||||
`Default model set to ${result.defaultModel}`,
|
|
||||||
"Model configured",
|
|
||||||
);
|
|
||||||
} else if (params.agentId) {
|
|
||||||
agentModelOverride = result.defaultModel;
|
|
||||||
await params.prompter.note(
|
|
||||||
`Default model set to ${result.defaultModel} for agent "${params.agentId}".`,
|
|
||||||
"Model configured",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.notes && result.notes.length > 0) {
|
|
||||||
await params.prompter.note(result.notes.join("\n"), "Provider notes");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { config: nextConfig, agentModelOverride };
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,195 @@
|
|||||||
|
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
|
||||||
|
import {
|
||||||
|
resolveDefaultAgentId,
|
||||||
|
resolveAgentDir,
|
||||||
|
resolveAgentWorkspaceDir,
|
||||||
|
} from "../agents/agent-scope.js";
|
||||||
|
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||||
|
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||||
|
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { resolvePluginProviders } from "../plugins/providers.js";
|
||||||
|
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
|
||||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||||
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
|
import { applyAuthProfileConfig } from "./onboard-auth.js";
|
||||||
|
import { openUrl } from "./onboard-helpers.js";
|
||||||
|
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
|
||||||
|
import { isRemoteEnvironment } from "./oauth-env.js";
|
||||||
|
|
||||||
|
const PLUGIN_ID = "qwen-portal-auth";
|
||||||
|
const PROVIDER_ID = "qwen-portal";
|
||||||
|
|
||||||
|
function enableBundledPlugin(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||||
|
const existingEntry = cfg.plugins?.entries?.[PLUGIN_ID];
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
plugins: {
|
||||||
|
...cfg.plugins,
|
||||||
|
entries: {
|
||||||
|
...cfg.plugins?.entries,
|
||||||
|
[PLUGIN_ID]: {
|
||||||
|
...existingEntry,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProviderMatch(
|
||||||
|
providers: ProviderPlugin[],
|
||||||
|
rawProvider: string,
|
||||||
|
): ProviderPlugin | null {
|
||||||
|
const normalized = normalizeProviderId(rawProvider);
|
||||||
|
return (
|
||||||
|
providers.find((provider) => normalizeProviderId(provider.id) === normalized) ??
|
||||||
|
providers.find(
|
||||||
|
(provider) =>
|
||||||
|
provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false,
|
||||||
|
) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null {
|
||||||
|
const raw = rawMethod?.trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const normalized = raw.toLowerCase();
|
||||||
|
return (
|
||||||
|
provider.auth.find((method) => method.id.toLowerCase() === normalized) ??
|
||||||
|
provider.auth.find((method) => method.label.toLowerCase() === normalized) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfigPatch<T>(base: T, patch: unknown): T {
|
||||||
|
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
|
||||||
|
return patch as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: Record<string, unknown> = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
const existing = next[key];
|
||||||
|
if (isPlainRecord(existing) && isPlainRecord(value)) {
|
||||||
|
next[key] = mergeConfigPatch(existing, value);
|
||||||
|
} else {
|
||||||
|
next[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
|
||||||
|
const models = { ...cfg.agents?.defaults?.models };
|
||||||
|
models[model] = models[model] ?? {};
|
||||||
|
|
||||||
|
const existingModel = cfg.agents?.defaults?.model;
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
agents: {
|
||||||
|
...cfg.agents,
|
||||||
|
defaults: {
|
||||||
|
...cfg.agents?.defaults,
|
||||||
|
models,
|
||||||
|
model: {
|
||||||
|
...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel
|
||||||
|
? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks }
|
||||||
|
: undefined),
|
||||||
|
primary: model,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyAuthChoiceQwenPortal(
|
export async function applyAuthChoiceQwenPortal(
|
||||||
params: ApplyAuthChoiceParams,
|
params: ApplyAuthChoiceParams,
|
||||||
): Promise<ApplyAuthChoiceResult | null> {
|
): Promise<ApplyAuthChoiceResult | null> {
|
||||||
return await applyAuthChoicePluginProvider(params, {
|
if (params.authChoice !== "qwen-portal") return null;
|
||||||
authChoice: "qwen-portal",
|
|
||||||
pluginId: "qwen-portal-auth",
|
let nextConfig = enableBundledPlugin(params.config);
|
||||||
providerId: "qwen-portal",
|
const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig);
|
||||||
methodId: "device",
|
const defaultAgentId = resolveDefaultAgentId(nextConfig);
|
||||||
label: "Qwen",
|
const agentDir =
|
||||||
|
params.agentDir ??
|
||||||
|
(agentId === defaultAgentId ? resolveClawdbotAgentDir() : resolveAgentDir(nextConfig, agentId));
|
||||||
|
const workspaceDir =
|
||||||
|
resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir();
|
||||||
|
|
||||||
|
const providers = resolvePluginProviders({ config: nextConfig, workspaceDir });
|
||||||
|
const provider = resolveProviderMatch(providers, PROVIDER_ID);
|
||||||
|
if (!provider) {
|
||||||
|
await params.prompter.note(
|
||||||
|
"Qwen auth plugin is not available. Run `clawdbot plugins enable qwen-portal-auth` and re-run the wizard.",
|
||||||
|
"Qwen",
|
||||||
|
);
|
||||||
|
return { config: nextConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = pickAuthMethod(provider, "device") ?? provider.auth[0];
|
||||||
|
if (!method) {
|
||||||
|
await params.prompter.note("Qwen auth method missing.", "Qwen");
|
||||||
|
return { config: nextConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRemote = isRemoteEnvironment();
|
||||||
|
const result = await method.run({
|
||||||
|
config: nextConfig,
|
||||||
|
agentDir,
|
||||||
|
workspaceDir,
|
||||||
|
prompter: params.prompter,
|
||||||
|
runtime: params.runtime,
|
||||||
|
isRemote,
|
||||||
|
openUrl: async (url) => {
|
||||||
|
await openUrl(url);
|
||||||
|
},
|
||||||
|
oauth: {
|
||||||
|
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.configPatch) {
|
||||||
|
nextConfig = mergeConfigPatch(nextConfig, result.configPatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const profile of result.profiles) {
|
||||||
|
upsertAuthProfile({
|
||||||
|
profileId: profile.profileId,
|
||||||
|
credential: profile.credential,
|
||||||
|
agentDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
|
profileId: profile.profileId,
|
||||||
|
provider: profile.credential.provider,
|
||||||
|
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
|
||||||
|
...("email" in profile.credential && profile.credential.email
|
||||||
|
? { email: profile.credential.email }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let agentModelOverride: string | undefined;
|
||||||
|
if (result.defaultModel) {
|
||||||
|
if (params.setDefaultModel) {
|
||||||
|
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
|
||||||
|
await params.prompter.note(`Default model set to ${result.defaultModel}`, "Model configured");
|
||||||
|
} else if (params.agentId) {
|
||||||
|
agentModelOverride = result.defaultModel;
|
||||||
|
await params.prompter.note(
|
||||||
|
`Default model set to ${result.defaultModel} for agent "${params.agentId}".`,
|
||||||
|
"Model configured",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.notes && result.notes.length > 0) {
|
||||||
|
await params.prompter.note(result.notes.join("\n"), "Provider notes");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,7 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||||
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
|
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
|
||||||
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js";
|
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js";
|
||||||
import { applyAuthChoiceCopilotProxy } from "./auth-choice.apply.copilot-proxy.js";
|
|
||||||
import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js";
|
import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js";
|
||||||
import { applyAuthChoiceGoogleAntigravity } from "./auth-choice.apply.google-antigravity.js";
|
|
||||||
import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js";
|
|
||||||
import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js";
|
import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js";
|
||||||
import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js";
|
import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js";
|
||||||
import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js";
|
import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js";
|
||||||
@@ -38,9 +35,6 @@ export async function applyAuthChoice(
|
|||||||
applyAuthChoiceApiProviders,
|
applyAuthChoiceApiProviders,
|
||||||
applyAuthChoiceMiniMax,
|
applyAuthChoiceMiniMax,
|
||||||
applyAuthChoiceGitHubCopilot,
|
applyAuthChoiceGitHubCopilot,
|
||||||
applyAuthChoiceGoogleAntigravity,
|
|
||||||
applyAuthChoiceGoogleGeminiCli,
|
|
||||||
applyAuthChoiceCopilotProxy,
|
|
||||||
applyAuthChoiceQwenPortal,
|
applyAuthChoiceQwenPortal,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user