Compare commits

..
Author SHA1 Message Date
Peter Steinberger fc4e1a434f docs: remove duplicate logging nav entry (#1106) (thanks @gumadeiras) 2026-01-17 17:31:58 +00:00
Gustavo Madeira Santana e9d1f3b030 Remove extra 'logging' page in the docs
Removed 'logging' from the list of gateways.
2026-01-17 17:12:00 +00:00
44 changed files with 233 additions and 964 deletions

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x. - macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg. - Docs: remove duplicate logging nav entry. (#1106) — thanks @gumadeiras.
## 2026.1.16-2 ## 2026.1.16-2
@@ -35,7 +35,7 @@ enum CLIInstaller {
} }
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async { static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
let prefix = Self.installPrefix() let prefix = Self.installPrefix()
await statusHandler("Installing clawdbot CLI…") await statusHandler("Installing clawdbot CLI…")
let cmd = self.installScriptCommand(version: expected, prefix: prefix) let cmd = self.installScriptCommand(version: expected, prefix: prefix)
@@ -6,11 +6,11 @@ struct ConfigSchemaForm: View {
let path: ConfigPath let path: ConfigPath
var body: some View { var body: some View {
self.renderNode(self.schema, path: self.path) self.renderNode(schema, path: path)
} }
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView { private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
let storedValue = self.store.configValue(at: path) let storedValue = store.configValue(at: path)
let value = storedValue ?? schema.explicitDefault let value = storedValue ?? schema.explicitDefault
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
@@ -21,7 +21,7 @@ struct ConfigSchemaForm: View {
if nonNull.count == 1, let only = nonNull.first { if nonNull.count == 1, let only = nonNull.first {
return self.renderNode(only, path: path) return self.renderNode(only, path: path)
} }
let literals = nonNull.compactMap(\.literalValue) let literals = nonNull.compactMap { $0.literalValue }
if !literals.isEmpty, literals.count == nonNull.count { if !literals.isEmpty, literals.count == nonNull.count {
return AnyView( return AnyView(
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -31,20 +31,15 @@ struct ConfigSchemaForm: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Picker( Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) {
"",
selection: self.enumBinding(
path,
options: literals,
defaultValue: schema.explicitDefault))
{
Text("Select…").tag(-1) Text("Select…").tag(-1)
ForEach(literals.indices, id: \ .self) { index in ForEach(literals.indices, id: \ .self) { index in
Text(String(describing: literals[index])).tag(index) Text(String(describing: literals[index])).tag(index)
} }
} }
.pickerStyle(.menu) .pickerStyle(.menu)
}) }
)
} }
} }
@@ -76,7 +71,8 @@ struct ConfigSchemaForm: View {
if schema.allowsAdditionalProperties { if schema.allowsAdditionalProperties {
self.renderAdditionalProperties(schema, path: path, value: value) self.renderAdditionalProperties(schema, path: path, value: value)
} }
}) }
)
case "array": case "array":
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help)) return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean": case "boolean":
@@ -84,7 +80,8 @@ struct ConfigSchemaForm: View {
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) { Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
if let label { Text(label) } else { Text("Enabled") } if let label { Text(label) } else { Text("Enabled") }
} }
.help(help ?? "")) .help(help ?? "")
)
case "number", "integer": case "number", "integer":
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help)) return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
case "string": case "string":
@@ -96,7 +93,8 @@ struct ConfigSchemaForm: View {
Text("Unsupported field type.") Text("Unsupported field type.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
}) }
)
} }
} }
@@ -157,7 +155,9 @@ struct ConfigSchemaForm: View {
text: self.numberBinding( text: self.numberBinding(
path, path,
isInteger: schema.schemaType == "integer", isInteger: schema.schemaType == "integer",
defaultValue: defaultValue)) defaultValue: defaultValue
)
)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
} }
@@ -189,7 +189,7 @@ struct ConfigSchemaForm: View {
Button("Remove") { Button("Remove") {
var next = items var next = items
next.remove(at: index) next.remove(at: index)
self.store.updateConfigValue(path: path, value: next) store.updateConfigValue(path: path, value: next)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@@ -202,7 +202,7 @@ struct ConfigSchemaForm: View {
} else { } else {
next.append("") next.append("")
} }
self.store.updateConfigValue(path: path, value: next) store.updateConfigValue(path: path, value: next)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@@ -238,7 +238,7 @@ struct ConfigSchemaForm: View {
Button("Remove") { Button("Remove") {
var next = dict var next = dict
next.removeValue(forKey: key) next.removeValue(forKey: key)
self.store.updateConfigValue(path: path, value: next) store.updateConfigValue(path: path, value: next)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@@ -254,7 +254,7 @@ struct ConfigSchemaForm: View {
key = "new-\(index)" key = "new-\(index)"
} }
next[key] = additionalSchema.defaultValue next[key] = additionalSchema.defaultValue
self.store.updateConfigValue(path: path, value: next) store.updateConfigValue(path: path, value: next)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@@ -270,8 +270,9 @@ struct ConfigSchemaForm: View {
}, },
set: { newValue in set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed) store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
}) }
)
} }
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> { private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
@@ -281,15 +282,16 @@ struct ConfigSchemaForm: View {
return defaultValue ?? false return defaultValue ?? false
}, },
set: { newValue in set: { newValue in
self.store.updateConfigValue(path: path, value: newValue) store.updateConfigValue(path: path, value: newValue)
}) }
)
} }
private func numberBinding( private func numberBinding(
_ path: ConfigPath, _ path: ConfigPath,
isInteger: Bool, isInteger: Bool,
defaultValue: Double?) -> Binding<String> defaultValue: Double?
{ ) -> Binding<String> {
Binding( Binding(
get: { get: {
if let value = store.configValue(at: path) { return String(describing: value) } if let value = store.configValue(at: path) { return String(describing: value) }
@@ -299,21 +301,22 @@ struct ConfigSchemaForm: View {
set: { newValue in set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { if trimmed.isEmpty {
self.store.updateConfigValue(path: path, value: nil) store.updateConfigValue(path: path, value: nil)
} else if let value = Double(trimmed) { } else if let value = Double(trimmed) {
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value) store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
} }
}) }
)
} }
private func enumBinding( private func enumBinding(
_ path: ConfigPath, _ path: ConfigPath,
options: [Any], options: [Any],
defaultValue: Any?) -> Binding<Int> defaultValue: Any?
{ ) -> Binding<Int> {
Binding( Binding(
get: { get: {
let value = self.store.configValue(at: path) ?? defaultValue let value = store.configValue(at: path) ?? defaultValue
guard let value else { return -1 } guard let value else { return -1 }
return options.firstIndex { option in return options.firstIndex { option in
String(describing: option) == String(describing: value) String(describing: option) == String(describing: value)
@@ -321,11 +324,12 @@ struct ConfigSchemaForm: View {
}, },
set: { index in set: { index in
guard index >= 0, index < options.count else { guard index >= 0, index < options.count else {
self.store.updateConfigValue(path: path, value: nil) store.updateConfigValue(path: path, value: nil)
return return
} }
self.store.updateConfigValue(path: path, value: options[index]) store.updateConfigValue(path: path, value: options[index])
}) }
)
} }
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> { private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
@@ -335,13 +339,14 @@ struct ConfigSchemaForm: View {
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } guard !trimmed.isEmpty else { return }
guard trimmed != key else { return } guard trimmed != key else { return }
let current = self.store.configValue(at: path) as? [String: Any] ?? [:] let current = store.configValue(at: path) as? [String: Any] ?? [:]
guard current[trimmed] == nil else { return } guard current[trimmed] == nil else { return }
var next = current var next = current
next[trimmed] = current[key] next[trimmed] = current[key]
next.removeValue(forKey: key) next.removeValue(forKey: key)
self.store.updateConfigValue(path: path, value: next) store.updateConfigValue(path: path, value: next)
}) }
)
} }
} }
@@ -350,10 +355,10 @@ struct ChannelConfigForm: View {
let channelId: String let channelId: String
var body: some View { var body: some View {
if self.store.configSchemaLoading { if store.configSchemaLoading {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
} else if let schema = store.channelConfigSchema(for: channelId) { } else if let schema = store.channelConfigSchema(for: channelId) {
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)]) ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
} else { } else {
Text("Schema unavailable for this channel.") Text("Schema unavailable for this channel.")
.font(.caption) .font(.caption)
@@ -434,25 +434,25 @@ extension ChannelsSettings {
private func resolveChannelDetailTitle(_ id: String) -> String { private func resolveChannelDetailTitle(_ id: String) -> String {
switch id { switch id {
case "whatsapp": "WhatsApp Web" case "whatsapp": return "WhatsApp Web"
case "telegram": "Telegram Bot" case "telegram": return "Telegram Bot"
case "discord": "Discord Bot" case "discord": return "Discord Bot"
case "slack": "Slack Bot" case "slack": return "Slack Bot"
case "signal": "Signal REST" case "signal": return "Signal REST"
case "imessage": "iMessage" case "imessage": return "iMessage"
default: self.resolveChannelTitle(id) default: return self.resolveChannelTitle(id)
} }
} }
private func resolveChannelSystemImage(_ id: String) -> String { private func resolveChannelSystemImage(_ id: String) -> String {
switch id { switch id {
case "whatsapp": "message" case "whatsapp": return "message"
case "telegram": "paperplane" case "telegram": return "paperplane"
case "discord": "bubble.left.and.bubble.right" case "discord": return "bubble.left.and.bubble.right"
case "slack": "number" case "slack": return "number"
case "signal": "antenna.radiowaves.left.and.right" case "signal": return "antenna.radiowaves.left.and.right"
case "imessage": "message.fill" case "imessage": return "message.fill"
default: "message" default: return "message"
} }
} }
@@ -57,7 +57,7 @@ extension ChannelsStore {
return value return value
} }
guard path.count >= 2 else { return nil } guard path.count >= 2 else { return nil }
if case .key("channels") = path[0], case .key = path[1] { if case .key("channels") = path[0], case .key(_) = path[1] {
let fallbackPath = Array(path.dropFirst()) let fallbackPath = Array(path.dropFirst())
return valueAtPath(self.configDraft, path: fallbackPath) return valueAtPath(self.configDraft, path: fallbackPath)
} }
@@ -93,10 +93,10 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
var current: Any? = root var current: Any? = root
for segment in path { for segment in path {
switch segment { switch segment {
case let .key(key): case .key(let key):
guard let dict = current as? [String: Any] else { return nil } guard let dict = current as? [String: Any] else { return nil }
current = dict[key] current = dict[key]
case let .index(index): case .index(let index):
guard let array = current as? [Any], array.indices.contains(index) else { return nil } guard let array = current as? [Any], array.indices.contains(index) else { return nil }
current = array[index] current = array[index]
} }
@@ -107,7 +107,7 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) { private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
guard let segment = path.first else { return } guard let segment = path.first else { return }
switch segment { switch segment {
case let .key(key): case .key(let key):
var dict = root as? [String: Any] ?? [:] var dict = root as? [String: Any] ?? [:]
if path.count == 1 { if path.count == 1 {
if let value { if let value {
@@ -122,7 +122,7 @@ private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
setValue(&child, path: Array(path.dropFirst()), value: value) setValue(&child, path: Array(path.dropFirst()), value: value)
dict[key] = child dict[key] = child
root = dict root = dict
case let .index(index): case .index(let index):
var array = root as? [Any] ?? [] var array = root as? [Any] ?? []
if index >= array.count { if index >= array.count {
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1)) array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
@@ -133,7 +133,7 @@ struct ConfigSchemaNode {
for segment in path { for segment in path {
guard let node = current else { return nil } guard let node = current else { return nil }
switch segment { switch segment {
case let .key(key): case .key(let key):
if node.schemaType == "object" { if node.schemaType == "object" {
if let next = node.properties[key] { if let next = node.properties[key] {
current = next current = next
@@ -174,7 +174,7 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
var match = true var match = true
for (index, seg) in segments.enumerated() { for (index, seg) in segments.enumerated() {
let hintSegment = hintSegments[index] let hintSegment = hintSegments[index]
if hintSegment != "*", hintSegment != seg { if hintSegment != "*" && hintSegment != seg {
match = false match = false
break break
} }
@@ -196,7 +196,7 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
func pathKey(_ path: ConfigPath) -> String { func pathKey(_ path: ConfigPath) -> String {
path.compactMap { segment -> String? in path.compactMap { segment -> String? in
switch segment { switch segment {
case let .key(key): return key case .key(let key): return key
case .index: return nil case .index: return nil
} }
} }
@@ -47,7 +47,7 @@ extension ConfigSettings {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
if self.store.configDirty, !self.isNixMode { if self.store.configDirty && !self.isNixMode {
Text("Unsaved changes") Text("Unsaved changes")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -80,13 +80,8 @@ enum GatewayEnvironment {
} }
static func expectedGatewayVersion() -> Semver? { static func expectedGatewayVersion() -> Semver? {
Semver.parse(self.expectedGatewayVersionString())
}
static func expectedGatewayVersionString() -> String? {
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines) return Semver.parse(bundleVersion)
return (trimmed?.isEmpty == false) ? trimmed : nil
} }
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata. // Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
@@ -105,7 +100,6 @@ enum GatewayEnvironment {
} }
} }
let expected = self.expectedGatewayVersion() let expected = self.expectedGatewayVersion()
let expectedString = self.expectedGatewayVersionString()
let projectRoot = CommandResolver.projectRoot() let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
@@ -116,8 +110,8 @@ enum GatewayEnvironment {
kind: .missingNode, kind: .missingNode,
nodeVersion: nil, nodeVersion: nil,
gatewayVersion: nil, gatewayVersion: nil,
requiredGateway: expectedString, requiredGateway: expected?.description,
message: RuntimeLocator.describeFailure(err)) message: RuntimeLocator.describeFailure(err))
case let .success(runtime): case let .success(runtime):
let gatewayBin = CommandResolver.clawdbotExecutable() let gatewayBin = CommandResolver.clawdbotExecutable()
@@ -126,7 +120,7 @@ enum GatewayEnvironment {
kind: .missingGateway, kind: .missingGateway,
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: nil, gatewayVersion: nil,
requiredGateway: expectedString, requiredGateway: expected?.description,
message: "clawdbot CLI not found in PATH; install the CLI.") message: "clawdbot CLI not found in PATH; install the CLI.")
} }
@@ -134,14 +128,13 @@ enum GatewayEnvironment {
?? self.readLocalGatewayVersion(projectRoot: projectRoot) ?? self.readLocalGatewayVersion(projectRoot: projectRoot)
if let expected, let installed, !installed.compatible(with: expected) { if let expected, let installed, !installed.compatible(with: expected) {
let expectedText = expectedString ?? expected.description
return GatewayEnvironmentStatus( return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expectedText), kind: .incompatible(found: installed.description, required: expected.description),
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: installed.description, gatewayVersion: installed.description,
requiredGateway: expectedText, requiredGateway: expected.description,
message: """ message: """
Gateway version \(installed.description) is incompatible with app \(expectedText); Gateway version \(installed.description) is incompatible with app \(expected.description);
install or update the global package. install or update the global package.
""") """)
} }
@@ -159,7 +152,7 @@ enum GatewayEnvironment {
kind: .ok, kind: .ok,
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: gatewayVersionText, gatewayVersion: gatewayVersionText,
requiredGateway: expectedString, requiredGateway: expected?.description,
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)") message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
} }
} }
@@ -227,18 +220,8 @@ enum GatewayEnvironment {
} }
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async { static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
await self.installGlobal(versionString: version?.description, statusHandler: statusHandler)
}
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
let preferred = CommandResolver.preferredPaths().joined(separator: ":") let preferred = CommandResolver.preferredPaths().joined(separator: ":")
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines) let target = version?.description ?? "latest"
let target: String
if let trimmed, !trimmed.isEmpty {
target = trimmed
} else {
target = "latest"
}
let npm = CommandResolver.findExecutable(named: "npm") let npm = CommandResolver.findExecutable(named: "npm")
let pnpm = CommandResolver.findExecutable(named: "pnpm") let pnpm = CommandResolver.findExecutable(named: "pnpm")
let bun = CommandResolver.findExecutable(named: "bun") let bun = CommandResolver.findExecutable(named: "bun")
+1 -1
View File
@@ -155,7 +155,7 @@ struct OnboardingView: View {
var canAdvance: Bool { !self.isWizardBlocking } var canAdvance: Bool { !self.isWizardBlocking }
var devLinkCommand: String { var devLinkCommand: String {
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
return "npm install -g clawdbot@\(version)" return "npm install -g clawdbot@\(version)"
} }
@@ -9,14 +9,8 @@ import Testing
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped #expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped #expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped #expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
#expect(Semver.parse(nil) == nil) #expect(Semver.parse(nil) == nil)
#expect(Semver.parse("invalid") == nil) #expect(Semver.parse("invalid") == nil)
#expect(Semver.parse("1.2") == nil)
#expect(Semver.parse("1.2.x") == nil)
} }
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() { @Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
@@ -44,7 +38,6 @@ import Testing
@Test func expectedGatewayVersionFromStringUsesParser() { @Test func expectedGatewayVersionFromStringUsesParser() {
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2)) #expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil) #expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
} }
} }
-13
View File
@@ -360,19 +360,6 @@ To force a voice note bubble in agent replies, include this tag anywhere in the
The tag is stripped from the delivered text. Other channels ignore this tag. The tag is stripped from the delivered text. Other channels ignore this tag.
For message tool sends, set `asVoice: true` with a voice-compatible audio `media` URL
(`message` is optional when media is present):
```json5
{
"action": "send",
"channel": "telegram",
"to": "123456789",
"media": "https://example.com/voice.ogg",
"asVoice": true
}
```
## Streaming (drafts) ## Streaming (drafts)
Telegram can stream **draft bubbles** while the agent is generating a response. Telegram can stream **draft bubbles** while the agent is generating a response.
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
+1 -2
View File
@@ -292,7 +292,7 @@ Options:
- `--non-interactive` - `--non-interactive`
- `--mode <local|remote>` - `--mode <local|remote>`
- `--flow <quickstart|advanced>` - `--flow <quickstart|advanced>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>` - `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`) - `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`) - `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`) - `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@@ -302,7 +302,6 @@ Options:
- `--openrouter-api-key <key>` - `--openrouter-api-key <key>`
- `--ai-gateway-api-key <key>` - `--ai-gateway-api-key <key>`
- `--moonshot-api-key <key>` - `--moonshot-api-key <key>`
- `--kimi-code-api-key <key>`
- `--gemini-api-key <key>` - `--gemini-api-key <key>`
- `--zai-api-key <key>` - `--zai-api-key <key>`
- `--minimax-api-key <key>` - `--minimax-api-key <key>`
-28
View File
@@ -155,34 +155,6 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
} }
``` ```
### Kimi Code
Kimi Code uses a dedicated endpoint and key (separate from Moonshot):
- Provider: `kimi-code`
- Auth: `KIMICODE_API_KEY`
- Example model: `kimi-code/kimi-for-coding`
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: { model: { primary: "kimi-code/kimi-for-coding" } }
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [{ id: "kimi-for-coding", name: "Kimi For Coding" }]
}
}
}
}
```
### Synthetic ### Synthetic
Synthetic provides Anthropic-compatible models behind the `synthetic` provider: Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
-43
View File
@@ -2234,49 +2234,6 @@ Notes:
- Model ref: `moonshot/kimi-k2-0905-preview`. - Model ref: `moonshot/kimi-k2-0905-preview`.
- Use `https://api.moonshot.cn/v1` if you need the China endpoint. - Use `https://api.moonshot.cn/v1` if you need the China endpoint.
### Kimi Code
Use Kimi Code's dedicated OpenAI-compatible endpoint (separate from Moonshot):
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi-code/kimi-for-coding" },
models: { "kimi-code/kimi-for-coding": { alias: "Kimi Code" } }
}
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [
{
id: "kimi-for-coding",
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
headers: { "User-Agent": "KimiCLI/0.77" },
compat: { supportsDeveloperRole: false }
}
]
}
}
}
}
```
Notes:
- Set `KIMICODE_API_KEY` in the environment or use `clawdbot onboard --auth-choice kimi-code-api-key`.
- Model ref: `kimi-code/kimi-for-coding`.
### Synthetic (Anthropic-compatible) ### Synthetic (Anthropic-compatible)
Use Synthetic's Anthropic-compatible endpoint: Use Synthetic's Anthropic-compatible endpoint:
+1 -1
View File
@@ -28,7 +28,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
- [Anthropic (API + Claude Code CLI)](/providers/anthropic) - [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter) - [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway) - [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot) - [Moonshot AI (Kimi)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode) - [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai) - [Z.AI](/providers/zai)
- [GLM models](/providers/glm) - [GLM models](/providers/glm)
+1 -1
View File
@@ -26,7 +26,7 @@ model as `provider/model`.
- [Anthropic (API + Claude Code CLI)](/providers/anthropic) - [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter) - [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway) - [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot) - [Moonshot AI (Kimi)](/providers/moonshot)
- [Synthetic](/providers/synthetic) - [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode) - [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai) - [Z.AI](/providers/zai)
+6 -56
View File
@@ -1,16 +1,13 @@
--- ---
summary: "Configure Moonshot K2 vs Kimi Code (separate providers + keys)" summary: "Use Moonshot AI (Kimi K2) with Clawdbot"
read_when: read_when:
- You want Moonshot K2 (Moonshot Open Platform) vs Kimi Code setup - You want to use Moonshot/Kimi models in Clawdbot
- You need to understand separate endpoints, keys, and model refs - You need the Moonshot auth + config example
- You want copy/paste config for either provider
--- ---
# Moonshot AI (Kimi) # Moonshot AI (Kimi)
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use provider and set the default model to `moonshot/kimi-k2-0905-preview`.
Kimi Code with `kimi-code/kimi-for-coding`.
Current Kimi K2 model IDs: Current Kimi K2 model IDs:
{/* moonshot-kimi-k2-ids:start */} {/* moonshot-kimi-k2-ids:start */}
@@ -24,15 +21,7 @@ Current Kimi K2 model IDs:
clawdbot onboard --auth-choice moonshot-api-key clawdbot onboard --auth-choice moonshot-api-key
``` ```
Kimi Code: ## Config snippet
```bash
clawdbot onboard --auth-choice kimi-code-api-key
```
Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeable, endpoints differ, and model refs differ (Moonshot uses `moonshot/...`, Kimi Code uses `kimi-code/...`).
## Config snippet (Moonshot API)
```json5 ```json5
{ {
@@ -103,48 +92,9 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl
} }
``` ```
## Kimi Code
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi-code/kimi-for-coding" },
models: {
"kimi-code/kimi-for-coding": { alias: "Kimi Code" }
}
}
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [
{
id: "kimi-for-coding",
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
headers: { "User-Agent": "KimiCLI/0.77" },
compat: { supportsDeveloperRole: false }
}
]
}
}
}
}
```
## Notes ## Notes
- Moonshot model refs use `moonshot/<modelId>`. Kimi Code model refs use `kimi-code/<modelId>`. - Model refs use `moonshot/<modelId>`.
- Override pricing and context metadata in `models.providers` if needed. - Override pricing and context metadata in `models.providers` if needed.
- If Moonshot publishes different context limits for a model, adjust - If Moonshot publishes different context limits for a model, adjust
`contextWindow` accordingly. `contextWindow` accordingly.
+1 -2
View File
@@ -95,8 +95,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`. - **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
- More detail: [Synthetic](/providers/synthetic) - More detail: [Synthetic](/providers/synthetic)
- **Moonshot (Kimi K2)**: config is auto-written. - **Moonshot (Kimi K2)**: config is auto-written.
- **Kimi Code**: config is auto-written. - More detail: [Moonshot AI](/providers/moonshot)
- More detail: [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- **Skip**: no auth configured yet. - **Skip**: no auth configured yet.
- Pick a default model from detected options (or enter provider/model manually). - Pick a default model from detected options (or enter provider/model manually).
- Wizard runs a model check and warns if the configured model is unknown or missing auth. - Wizard runs a model check and warns if the configured model is unknown or missing auth.
@@ -2,7 +2,7 @@ import type { MatrixClient } from "matrix-js-sdk";
import { chunkMarkdownText } from "../../../../../src/auto-reply/chunk.js"; import { chunkMarkdownText } from "../../../../../src/auto-reply/chunk.js";
import type { ReplyPayload } from "../../../../../src/auto-reply/types.js"; import type { ReplyPayload } from "../../../../../src/auto-reply/types.js";
import { danger, logVerbose } from "../../../../../src/globals.js"; import { danger } from "../../../../../src/globals.js";
import type { RuntimeEnv } from "../../../../../src/runtime.js"; import type { RuntimeEnv } from "../../../../../src/runtime.js";
import { sendMessageMatrix } from "../send.js"; import { sendMessageMatrix } from "../send.js";
@@ -18,12 +18,7 @@ export async function deliverMatrixReplies(params: {
const chunkLimit = Math.min(params.textLimit, 4000); const chunkLimit = Math.min(params.textLimit, 4000);
let hasReplied = false; let hasReplied = false;
for (const reply of params.replies) { for (const reply of params.replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) {
if (!reply?.text && !hasMedia) {
if (reply?.audioAsVoice) {
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue;
}
params.runtime.error?.(danger("matrix reply missing text/media")); params.runtime.error?.(danger("matrix reply missing text/media"));
continue; continue;
} }
@@ -62,7 +57,6 @@ export async function deliverMatrixReplies(params: {
mediaUrl, mediaUrl,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId, threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
}); });
if (shouldIncludeReply(replyToId)) { if (shouldIncludeReply(replyToId)) {
hasReplied = true; hasReplied = true;
@@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest";
import { parsePollStartContent } from "./poll-types.js";
describe("parsePollStartContent", () => {
it("parses legacy m.poll payloads", () => {
const summary = parsePollStartContent({
"m.poll": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "answer1", "m.text": "Yes" },
{ id: "answer2", "m.text": "No" },
],
},
});
expect(summary?.question).toBe("Lunch?");
expect(summary?.answers).toEqual(["Yes", "No"]);
});
});
+28 -35
View File
@@ -7,17 +7,15 @@
* - m.poll.end - Closes a poll * - m.poll.end - Closes a poll
*/ */
import type { TimelineEvents } from "matrix-js-sdk/lib/@types/event.js";
import type { ExtensibleAnyMessageEventContent } from "matrix-js-sdk/lib/@types/extensible_events.js";
import type { PollInput } from "../../../../src/polls.js"; import type { PollInput } from "../../../../src/polls.js";
export const M_POLL_START = "m.poll.start" as const; export const M_POLL_START = "m.poll.start";
export const M_POLL_RESPONSE = "m.poll.response" as const; export const M_POLL_RESPONSE = "m.poll.response";
export const M_POLL_END = "m.poll.end" as const; export const M_POLL_END = "m.poll.end";
export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const; export const ORG_POLL_START = "org.matrix.msc3381.poll.start";
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const; export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response";
export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const; export const ORG_POLL_END = "org.matrix.msc3381.poll.end";
export const POLL_EVENT_TYPES = [ export const POLL_EVENT_TYPES = [
M_POLL_START, M_POLL_START,
@@ -34,7 +32,9 @@ export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed"; export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
export type TextContent = ExtensibleAnyMessageEventContent & { export type TextContent = {
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
body?: string; body?: string;
}; };
@@ -42,19 +42,25 @@ export type PollAnswer = {
id: string; id: string;
} & TextContent; } & TextContent;
export type PollStartSubtype = { export type PollStartContent = {
question: TextContent; "m.poll"?: {
kind?: PollKind; question: TextContent;
max_selections?: number; kind?: PollKind;
answers: PollAnswer[]; max_selections?: number;
answers: PollAnswer[];
};
"org.matrix.msc3381.poll.start"?: {
question: TextContent;
kind?: PollKind;
max_selections?: number;
answers: PollAnswer[];
};
"m.relates_to"?: {
rel_type: "m.reference";
event_id: string;
};
}; };
export type LegacyPollStartContent = {
"m.poll"?: PollStartSubtype;
};
export type PollStartContent = TimelineEvents[typeof M_POLL_START] | LegacyPollStartContent;
export type PollSummary = { export type PollSummary = {
eventId: string; eventId: string;
roomId: string; roomId: string;
@@ -76,9 +82,7 @@ export function getTextContent(text?: TextContent): string {
} }
export function parsePollStartContent(content: PollStartContent): PollSummary | null { export function parsePollStartContent(content: PollStartContent): PollSummary | null {
const poll = (content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] const poll = content["m.poll"] ?? content["org.matrix.msc3381.poll.start"];
?? (content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START]
?? (content as Record<string, PollStartSubtype | undefined>)["m.poll"];
if (!poll) return null; if (!poll) return null;
const question = getTextContent(poll.question); const question = getTextContent(poll.question);
@@ -117,11 +121,6 @@ function buildTextContent(body: string): TextContent {
}; };
} }
function buildPollFallbackText(question: string, answers: string[]): string {
if (answers.length === 0) return question;
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
}
export function buildPollStartContent(poll: PollInput): PollStartContent { export function buildPollStartContent(poll: PollInput): PollStartContent {
const question = poll.question.trim(); const question = poll.question.trim();
const answers = poll.options const answers = poll.options
@@ -133,19 +132,13 @@ export function buildPollStartContent(poll: PollInput): PollStartContent {
})); }));
const maxSelections = poll.multiple ? Math.max(1, answers.length) : 1; const maxSelections = poll.multiple ? Math.max(1, answers.length) : 1;
const fallbackText = buildPollFallbackText(
question,
answers.map((answer) => getTextContent(answer)),
);
return { return {
[M_POLL_START]: { "m.poll": {
question: buildTextContent(question), question: buildTextContent(question),
kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed", kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed",
max_selections: maxSelections, max_selections: maxSelections,
answers, answers,
}, },
"m.text": fallbackText,
"org.matrix.msc1767.text": fallbackText,
}; };
} }
+2 -7
View File
@@ -31,11 +31,6 @@ vi.mock("../../../../src/web/media.js", () => ({
}), }),
})); }));
vi.mock("../../../../src/media/image-ops.js", () => ({
getImageMetadata: vi.fn().mockResolvedValue(null),
resizeToJpeg: vi.fn(),
}));
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
const makeClient = () => { const makeClient = () => {
@@ -70,13 +65,13 @@ describe("sendMessageMatrix media", () => {
const uploadArg = uploadContent.mock.calls[0]?.[0]; const uploadArg = uploadContent.mock.calls[0]?.[0];
expect(Buffer.isBuffer(uploadArg)).toBe(true); expect(Buffer.isBuffer(uploadArg)).toBe(true);
const content = sendMessage.mock.calls[0]?.[1] as { const content = sendMessage.mock.calls[0]?.[2] as {
url?: string; url?: string;
msgtype?: string; msgtype?: string;
format?: string; format?: string;
formatted_body?: string; formatted_body?: string;
}; };
expect(content.msgtype).toBe("m.image"); expect(content.msgtype).toBe("m.file");
expect(content.format).toBe("org.matrix.custom.html"); expect(content.format).toBe("org.matrix.custom.html");
expect(content.formatted_body).toContain("caption"); expect(content.formatted_body).toContain("caption");
expect(content.url).toBe("mxc://example/file"); expect(content.url).toBe("mxc://example/file");
+22 -145
View File
@@ -1,15 +1,12 @@
import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk"; import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk";
import { EventType, MsgType, RelationType } from "matrix-js-sdk"; import { EventType, MsgType, RelationType } from "matrix-js-sdk";
import type { import type {
RoomMessageEventContent,
ReactionEventContent, ReactionEventContent,
RoomMessageEventContent,
} from "matrix-js-sdk/lib/@types/events.js"; } from "matrix-js-sdk/lib/@types/events.js";
import { chunkMarkdownText, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; import { chunkMarkdownText, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
import { loadConfig } from "../../../../src/config/config.js"; import { loadConfig } from "../../../../src/config/config.js";
import { isVoiceCompatibleAudio } from "../../../../src/media/audio.js";
import { mediaKindFromMime } from "../../../../src/media/constants.js";
import { getImageMetadata, resizeToJpeg } from "../../../../src/media/image-ops.js";
import type { PollInput } from "../../../../src/polls.js"; import type { PollInput } from "../../../../src/polls.js";
import { loadWebMedia } from "../../../../src/web/media.js"; import { loadWebMedia } from "../../../../src/web/media.js";
import { getActiveMatrixClient } from "./active-client.js"; import { getActiveMatrixClient } from "./active-client.js";
@@ -50,8 +47,6 @@ export type MatrixSendOpts = {
replyToId?: string; replyToId?: string;
threadId?: string | number | null; threadId?: string | number | null;
timeoutMs?: number; timeoutMs?: number;
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
}; };
function ensureNodeRuntime() { function ensureNodeRuntime() {
@@ -76,12 +71,6 @@ function normalizeTarget(raw: string): string {
return trimmed; return trimmed;
} }
function normalizeThreadId(raw?: string | number | null): string | null {
if (raw === undefined || raw === null) return null;
const trimmed = String(raw).trim();
return trimmed ? trimmed : null;
}
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> { async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
const trimmed = userId.trim(); const trimmed = userId.trim();
if (!trimmed.startsWith("@")) { if (!trimmed.startsWith("@")) {
@@ -130,18 +119,6 @@ export async function resolveMatrixRoomId(
return target; return target;
} }
type MatrixImageInfo = {
w?: number;
h?: number;
thumbnail_url?: string;
thumbnail_info?: {
w: number;
h: number;
mimetype: string;
size: number;
};
};
function buildMediaContent(params: { function buildMediaContent(params: {
msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File; msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File;
body: string; body: string;
@@ -150,24 +127,8 @@ function buildMediaContent(params: {
mimetype?: string; mimetype?: string;
size: number; size: number;
relation?: MatrixReplyRelation; relation?: MatrixReplyRelation;
isVoice?: boolean;
durationMs?: number;
imageInfo?: MatrixImageInfo;
}): RoomMessageEventContent { }): RoomMessageEventContent {
const info: Record<string, unknown> = { mimetype: params.mimetype, size: params.size }; const info = { mimetype: params.mimetype, size: params.size };
if (params.durationMs !== undefined) {
info.duration = params.durationMs;
}
if (params.imageInfo) {
if (params.imageInfo.w) info.w = params.imageInfo.w;
if (params.imageInfo.h) info.h = params.imageInfo.h;
if (params.imageInfo.thumbnail_url) {
info.thumbnail_url = params.imageInfo.thumbnail_url;
if (params.imageInfo.thumbnail_info) {
info.thumbnail_info = params.imageInfo.thumbnail_info;
}
}
}
const base: MatrixMessageContent = { const base: MatrixMessageContent = {
msgtype: params.msgtype, msgtype: params.msgtype,
body: params.body, body: params.body,
@@ -175,12 +136,6 @@ function buildMediaContent(params: {
info, info,
url: params.url, url: params.url,
}; };
if (params.isVoice) {
base["org.matrix.msc3245.voice"] = {};
base["org.matrix.msc1767.audio"] = {
duration: params.durationMs,
};
}
if (params.relation) { if (params.relation) {
base["m.relates_to"] = params.relation; base["m.relates_to"] = params.relation;
} }
@@ -216,75 +171,6 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined
return { "m.in_reply_to": { event_id: trimmed } }; return { "m.in_reply_to": { event_id: trimmed } };
} }
function resolveMatrixMsgType(
contentType?: string,
fileName?: string,
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
const kind = mediaKindFromMime(contentType ?? "");
switch (kind) {
case "image":
return MsgType.Image;
case "audio":
return MsgType.Audio;
case "video":
return MsgType.Video;
default:
return MsgType.File;
}
}
function resolveMatrixVoiceDecision(opts: {
wantsVoice: boolean;
contentType?: string;
fileName?: string;
}): { useVoice: boolean } {
if (!opts.wantsVoice) return { useVoice: false };
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
return { useVoice: true };
}
return { useVoice: false };
}
const THUMBNAIL_MAX_SIDE = 800;
const THUMBNAIL_QUALITY = 80;
async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
}): Promise<MatrixImageInfo | undefined> {
const meta = await getImageMetadata(params.buffer).catch(() => null);
if (!meta) return undefined;
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
const thumbBuffer = await resizeToJpeg({
buffer: params.buffer,
maxSide: THUMBNAIL_MAX_SIDE,
quality: THUMBNAIL_QUALITY,
withoutEnlargement: true,
});
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null);
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
type: "image/jpeg",
name: "thumbnail.jpg",
});
imageInfo.thumbnail_url = thumbUri.content_uri;
if (thumbMeta) {
imageInfo.thumbnail_info = {
w: thumbMeta.width,
h: thumbMeta.height,
mimetype: "image/jpeg",
size: thumbBuffer.byteLength,
};
}
} catch {
// Thumbnail generation failed, continue without it
}
}
return imageInfo;
}
async function uploadFile( async function uploadFile(
client: MatrixClient, client: MatrixClient,
file: MatrixUploadContent | Buffer, file: MatrixUploadContent | Buffer,
@@ -352,10 +238,14 @@ export async function sendMessageMatrix(
const textLimit = resolveTextChunkLimit(cfg, "matrix"); const textLimit = resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit); const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId); const rawThreadId = opts.threadId;
const threadId =
rawThreadId !== undefined && rawThreadId !== null
? String(rawThreadId).trim()
: null;
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId); const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
const sendContent = (content: RoomMessageEventContent) => const sendContent = (content: RoomMessageEventContent) =>
threadId ? client.sendMessage(roomId, threadId, content) : client.sendMessage(roomId, content); client.sendMessage(roomId, threadId ?? undefined, content);
let lastMessageId = ""; let lastMessageId = "";
if (opts.mediaUrl) { if (opts.mediaUrl) {
@@ -365,17 +255,9 @@ export async function sendMessageMatrix(
contentType: media.contentType, contentType: media.contentType,
filename: media.fileName, filename: media.fileName,
}); });
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); const msgtype = MsgType.File;
const { useVoice } = resolveMatrixVoiceDecision({
wantsVoice: opts.audioAsVoice === true,
contentType: media.contentType,
fileName: media.fileName,
});
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
const isImage = msgtype === MsgType.Image;
const imageInfo = isImage ? await prepareImageInfo({ buffer: media.buffer, client }) : undefined;
const [firstChunk, ...rest] = chunks; const [firstChunk, ...rest] = chunks;
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); const body = firstChunk ?? media.fileName ?? "(file)";
const content = buildMediaContent({ const content = buildMediaContent({
msgtype, msgtype,
body, body,
@@ -384,13 +266,10 @@ export async function sendMessageMatrix(
mimetype: media.contentType, mimetype: media.contentType,
size: media.buffer.byteLength, size: media.buffer.byteLength,
relation, relation,
isVoice: useVoice,
imageInfo,
}); });
const response = await sendContent(content); const response = await sendContent(content);
lastMessageId = response.event_id ?? lastMessageId; lastMessageId = response.event_id ?? lastMessageId;
const textChunks = useVoice ? chunks : rest; for (const chunk of rest) {
for (const chunk of textChunks) {
const text = chunk.trim(); const text = chunk.trim();
if (!text) continue; if (!text) continue;
const followup = buildTextContent(text); const followup = buildTextContent(text);
@@ -437,19 +316,17 @@ export async function sendPollMatrix(
try { try {
const roomId = await resolveMatrixRoomId(client, to); const roomId = await resolveMatrixRoomId(client, to);
const pollContent = buildPollStartContent(poll); const pollContent = buildPollStartContent(poll);
const threadId = normalizeThreadId(opts.threadId); const rawThreadId = opts.threadId;
const response = threadId const threadId =
? await client.sendEvent( rawThreadId !== undefined && rawThreadId !== null
roomId, ? String(rawThreadId).trim()
threadId, : null;
M_POLL_START, const response = await client.sendEvent(
pollContent, roomId,
) threadId ?? undefined,
: await client.sendEvent( M_POLL_START as EventType.RoomMessage,
roomId, pollContent as unknown as RoomMessageEventContent,
M_POLL_START, );
pollContent,
);
return { return {
eventId: response.event_id ?? "unknown", eventId: response.event_id ?? "unknown",
-1
View File
@@ -157,7 +157,6 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY", "vercel-ai-gateway": "AI_GATEWAY_API_KEY",
moonshot: "MOONSHOT_API_KEY", moonshot: "MOONSHOT_API_KEY",
"kimi-code": "KIMICODE_API_KEY",
minimax: "MINIMAX_API_KEY", minimax: "MINIMAX_API_KEY",
synthetic: "SYNTHETIC_API_KEY", synthetic: "SYNTHETIC_API_KEY",
mistral: "MISTRAL_API_KEY", mistral: "MISTRAL_API_KEY",
-39
View File
@@ -37,18 +37,6 @@ const MOONSHOT_DEFAULT_COST = {
cacheRead: 0, cacheRead: 0,
cacheWrite: 0, cacheWrite: 0,
}; };
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
const KIMI_CODE_MODEL_ID = "kimi-for-coding";
const KIMI_CODE_CONTEXT_WINDOW = 262144;
const KIMI_CODE_MAX_TOKENS = 32768;
const KIMI_CODE_HEADERS = { "User-Agent": "KimiCLI/0.77" } as const;
const KIMI_CODE_COMPAT = { supportsDeveloperRole: false } as const;
const KIMI_CODE_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
function normalizeApiKeyConfig(value: string): string { function normalizeApiKeyConfig(value: string): string {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -196,26 +184,6 @@ function buildMoonshotProvider(): ProviderConfig {
}; };
} }
function buildKimiCodeProvider(): ProviderConfig {
return {
baseUrl: KIMI_CODE_BASE_URL,
api: "openai-completions",
models: [
{
id: KIMI_CODE_MODEL_ID,
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: KIMI_CODE_DEFAULT_COST,
contextWindow: KIMI_CODE_CONTEXT_WINDOW,
maxTokens: KIMI_CODE_MAX_TOKENS,
headers: KIMI_CODE_HEADERS,
compat: KIMI_CODE_COMPAT,
},
],
};
}
function buildSyntheticProvider(): ProviderConfig { function buildSyntheticProvider(): ProviderConfig {
return { return {
baseUrl: SYNTHETIC_BASE_URL, baseUrl: SYNTHETIC_BASE_URL,
@@ -244,13 +212,6 @@ export function resolveImplicitProviders(params: { agentDir: string }): ModelsCo
providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey }; providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey };
} }
const kimiCodeKey =
resolveEnvApiKeyVarName("kimi-code") ??
resolveApiKeyFromProfiles({ provider: "kimi-code", store: authStore });
if (kimiCodeKey) {
providers["kimi-code"] = { ...buildKimiCodeProvider(), apiKey: kimiCodeKey };
}
const syntheticKey = const syntheticKey =
resolveEnvApiKeyVarName("synthetic") ?? resolveEnvApiKeyVarName("synthetic") ??
resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore }); resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore });
-1
View File
@@ -39,7 +39,6 @@ function buildSendSchema(options: { includeButtons: boolean }) {
media: Type.Optional(Type.String()), media: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()), replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()), threadId: Type.Optional(Type.String()),
asVoice: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()), bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()), gifPlayback: Type.Optional(Type.Boolean()),
buttons: Type.Optional( buttons: Type.Optional(
-1
View File
@@ -175,7 +175,6 @@ export async function handleTelegramAction(
buttons, buttons,
replyToMessageId: replyToMessageId ?? undefined, replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined, messageThreadId: messageThreadId ?? undefined,
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
}); });
return jsonResult({ return jsonResult({
ok: true, ok: true,
+1 -1
View File
@@ -7,7 +7,7 @@ import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => { describe("prependSystemEvents", () => {
it("adds a UTC timestamp to queued system events", async () => { it("adds a UTC timestamp to queued system events", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const timestamp = new Date("2026-01-12T20:19:17Z"); const timestamp = new Date("2026-01-12T20:19:17");
vi.setSystemTime(timestamp); vi.setSystemTime(timestamp);
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" }); enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
@@ -1,39 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../../config/config.js";
import { telegramMessageActions } from "./telegram.js";
const handleTelegramAction = vi.fn(async () => ({ ok: true }));
vi.mock("../../../agents/tools/telegram-actions.js", () => ({
handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args),
}));
describe("telegramMessageActions", () => {
it("allows media-only sends and passes asVoice", async () => {
handleTelegramAction.mockClear();
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
await telegramMessageActions.handleAction({
action: "send",
params: {
to: "123",
media: "https://example.com/voice.ogg",
asVoice: true,
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "sendMessage",
to: "123",
content: "",
mediaUrl: "https://example.com/voice.ogg",
asVoice: true,
}),
cfg,
);
});
});
+15 -25
View File
@@ -10,29 +10,6 @@ import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../t
const providerId = "telegram"; const providerId = "telegram";
function readTelegramSendParams(params: Record<string, unknown>) {
const to = readStringParam(params, "to", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false });
const content =
readStringParam(params, "message", {
required: !mediaUrl,
allowEmpty: true,
}) ?? "";
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined;
return {
to,
content,
mediaUrl: mediaUrl ?? undefined,
replyToMessageId: replyTo ?? undefined,
messageThreadId: threadId ?? undefined,
buttons,
asVoice,
};
}
export const telegramMessageActions: ChannelMessageActionAdapter = { export const telegramMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => { listActions: ({ cfg }) => {
const accounts = listEnabledTelegramAccounts(cfg).filter( const accounts = listEnabledTelegramAccounts(cfg).filter(
@@ -64,12 +41,25 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
}, },
handleAction: async ({ action, params, cfg, accountId }) => { handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "send") { if (action === "send") {
const sendParams = readTelegramSendParams(params); const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
return await handleTelegramAction( return await handleTelegramAction(
{ {
action: "sendMessage", action: "sendMessage",
...sendParams, to,
content,
mediaUrl: mediaUrl ?? undefined,
replyToMessageId: replyTo ?? undefined,
messageThreadId: threadId ?? undefined,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
buttons,
}, },
cfg, cfg,
); );
-23
View File
@@ -180,29 +180,6 @@ describe("cli program (smoke)", () => {
); );
}); });
it("passes kimi code api key to onboard", async () => {
const program = buildProgram();
await program.parseAsync(
[
"onboard",
"--non-interactive",
"--auth-choice",
"kimi-code-api-key",
"--kimi-code-api-key",
"sk-kimi-code-test",
],
{ from: "user" },
);
expect(onboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
nonInteractive: true,
authChoice: "kimi-code-api-key",
kimiCodeApiKey: "sk-kimi-code-test",
}),
runtime,
);
});
it("passes synthetic api key to onboard", async () => { it("passes synthetic api key to onboard", async () => {
const program = buildProgram(); const program = buildProgram();
await program.parseAsync( await program.parseAsync(
+1 -3
View File
@@ -51,7 +51,7 @@ export function registerOnboardCommand(program: Command) {
.option("--mode <mode>", "Wizard mode: local|remote") .option("--mode <mode>", "Wizard mode: local|remote")
.option( .option(
"--auth-choice <choice>", "--auth-choice <choice>",
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", "Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|synthetic-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
) )
.option( .option(
"--token-provider <id>", "--token-provider <id>",
@@ -68,7 +68,6 @@ export function registerOnboardCommand(program: Command) {
.option("--openrouter-api-key <key>", "OpenRouter API key") .option("--openrouter-api-key <key>", "OpenRouter API key")
.option("--ai-gateway-api-key <key>", "Vercel AI Gateway API key") .option("--ai-gateway-api-key <key>", "Vercel AI Gateway API key")
.option("--moonshot-api-key <key>", "Moonshot API key") .option("--moonshot-api-key <key>", "Moonshot API key")
.option("--kimi-code-api-key <key>", "Kimi Code API key")
.option("--gemini-api-key <key>", "Gemini API key") .option("--gemini-api-key <key>", "Gemini API key")
.option("--zai-api-key <key>", "Z.AI API key") .option("--zai-api-key <key>", "Z.AI API key")
.option("--minimax-api-key <key>", "MiniMax API key") .option("--minimax-api-key <key>", "MiniMax API key")
@@ -117,7 +116,6 @@ export function registerOnboardCommand(program: Command) {
openrouterApiKey: opts.openrouterApiKey as string | undefined, openrouterApiKey: opts.openrouterApiKey as string | undefined,
aiGatewayApiKey: opts.aiGatewayApiKey as string | undefined, aiGatewayApiKey: opts.aiGatewayApiKey as string | undefined,
moonshotApiKey: opts.moonshotApiKey as string | undefined, moonshotApiKey: opts.moonshotApiKey as string | undefined,
kimiCodeApiKey: opts.kimiCodeApiKey as string | undefined,
geminiApiKey: opts.geminiApiKey as string | undefined, geminiApiKey: opts.geminiApiKey as string | undefined,
zaiApiKey: opts.zaiApiKey as string | undefined, zaiApiKey: opts.zaiApiKey as string | undefined,
minimaxApiKey: opts.minimaxApiKey as string | undefined, minimaxApiKey: opts.minimaxApiKey as string | undefined,
-1
View File
@@ -100,7 +100,6 @@ describe("buildAuthChoiceOptions", () => {
}); });
expect(options.some((opt) => opt.value === "moonshot-api-key")).toBe(true); expect(options.some((opt) => opt.value === "moonshot-api-key")).toBe(true);
expect(options.some((opt) => opt.value === "kimi-code-api-key")).toBe(true);
}); });
it("includes Vercel AI Gateway auth choice", () => { it("includes Vercel AI Gateway auth choice", () => {
+2 -3
View File
@@ -79,8 +79,8 @@ const AUTH_CHOICE_GROUP_DEFS: {
{ {
value: "moonshot", value: "moonshot",
label: "Moonshot AI", label: "Moonshot AI",
hint: "Kimi K2 + Kimi Code", hint: "Kimi K2 preview",
choices: ["moonshot-api-key", "kimi-code-api-key"], choices: ["moonshot-api-key"],
}, },
{ {
value: "zai", value: "zai",
@@ -180,7 +180,6 @@ export function buildAuthChoiceOptions(params: {
label: "Vercel AI Gateway API key", label: "Vercel AI Gateway API key",
}); });
options.push({ value: "moonshot-api-key", label: "Moonshot AI API key" }); options.push({ value: "moonshot-api-key", label: "Moonshot AI API key" });
options.push({ value: "kimi-code-api-key", label: "Kimi Code API key" });
options.push({ value: "synthetic-api-key", label: "Synthetic API key" }); options.push({ value: "synthetic-api-key", label: "Synthetic API key" });
options.push({ options.push({
value: "github-copilot", value: "github-copilot",
@@ -13,8 +13,6 @@ import {
} from "./google-gemini-model-default.js"; } from "./google-gemini-model-default.js";
import { import {
applyAuthProfileConfig, applyAuthProfileConfig,
applyKimiCodeConfig,
applyKimiCodeProviderConfig,
applyMoonshotConfig, applyMoonshotConfig,
applyMoonshotProviderConfig, applyMoonshotProviderConfig,
applyOpencodeZenConfig, applyOpencodeZenConfig,
@@ -26,13 +24,11 @@ import {
applyVercelAiGatewayConfig, applyVercelAiGatewayConfig,
applyVercelAiGatewayProviderConfig, applyVercelAiGatewayProviderConfig,
applyZaiConfig, applyZaiConfig,
KIMI_CODE_MODEL_REF,
MOONSHOT_DEFAULT_MODEL_REF, MOONSHOT_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF,
SYNTHETIC_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_REF,
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
setGeminiApiKey, setGeminiApiKey,
setKimiCodeApiKey,
setMoonshotApiKey, setMoonshotApiKey,
setOpencodeZenApiKey, setOpencodeZenApiKey,
setOpenrouterApiKey, setOpenrouterApiKey,
@@ -212,55 +208,6 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride }; return { config: nextConfig, agentModelOverride };
} }
if (params.authChoice === "kimi-code-api-key") {
await params.prompter.note(
[
"Kimi Code uses a dedicated endpoint and API key.",
"Get your API key at: https://www.kimi.com/code/en",
].join("\n"),
"Kimi Code",
);
let hasCredential = false;
const envKey = resolveEnvApiKey("kimi-code");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing KIMICODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
await setKimiCodeApiKey(envKey.apiKey, params.agentDir);
hasCredential = true;
}
}
if (!hasCredential) {
const key = await params.prompter.text({
message: "Enter Kimi Code API key",
validate: validateApiKeyInput,
});
await setKimiCodeApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "kimi-code:default",
provider: "kimi-code",
mode: "api_key",
});
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: KIMI_CODE_MODEL_REF,
applyDefaultConfig: applyKimiCodeConfig,
applyProviderConfig: applyKimiCodeProviderConfig,
noteDefault: KIMI_CODE_MODEL_REF,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "gemini-api-key") { if (params.authChoice === "gemini-api-key") {
let hasCredential = false; let hasCredential = false;
const envKey = resolveEnvApiKey("google"); const envKey = resolveEnvApiKey("google");
@@ -13,7 +13,6 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"openrouter-api-key": "openrouter", "openrouter-api-key": "openrouter",
"ai-gateway-api-key": "vercel-ai-gateway", "ai-gateway-api-key": "vercel-ai-gateway",
"moonshot-api-key": "moonshot", "moonshot-api-key": "moonshot",
"kimi-code-api-key": "kimi-code",
"gemini-api-key": "google", "gemini-api-key": "google",
"zai-api-key": "zai", "zai-api-key": "zai",
"synthetic-api-key": "synthetic", "synthetic-api-key": "synthetic",
-69
View File
@@ -11,11 +11,7 @@ import {
ZAI_DEFAULT_MODEL_REF, ZAI_DEFAULT_MODEL_REF,
} from "./onboard-auth.credentials.js"; } from "./onboard-auth.credentials.js";
import { import {
buildKimiCodeModelDefinition,
buildMoonshotModelDefinition, buildMoonshotModelDefinition,
KIMI_CODE_BASE_URL,
KIMI_CODE_MODEL_ID,
KIMI_CODE_MODEL_REF,
MOONSHOT_BASE_URL, MOONSHOT_BASE_URL,
MOONSHOT_DEFAULT_MODEL_ID, MOONSHOT_DEFAULT_MODEL_ID,
MOONSHOT_DEFAULT_MODEL_REF, MOONSHOT_DEFAULT_MODEL_REF,
@@ -196,71 +192,6 @@ export function applyMoonshotConfig(cfg: ClawdbotConfig): ClawdbotConfig {
}; };
} }
export function applyKimiCodeProviderConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const models = { ...cfg.agents?.defaults?.models };
models[KIMI_CODE_MODEL_REF] = {
...models[KIMI_CODE_MODEL_REF],
alias: models[KIMI_CODE_MODEL_REF]?.alias ?? "Kimi Code",
};
const providers = { ...cfg.models?.providers };
const existingProvider = providers["kimi-code"];
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
const defaultModel = buildKimiCodeModelDefinition();
const hasDefaultModel = existingModels.some((model) => model.id === KIMI_CODE_MODEL_ID);
const mergedModels = hasDefaultModel ? existingModels : [...existingModels, defaultModel];
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
string,
unknown
> as { apiKey?: string };
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
const normalizedApiKey = resolvedApiKey?.trim();
providers["kimi-code"] = {
...existingProviderRest,
baseUrl: KIMI_CODE_BASE_URL,
api: "openai-completions",
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : [defaultModel],
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: {
mode: cfg.models?.mode ?? "merge",
providers,
},
};
}
export function applyKimiCodeConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const next = applyKimiCodeProviderConfig(cfg);
const existingModel = next.agents?.defaults?.model;
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: KIMI_CODE_MODEL_REF,
},
},
},
};
}
export function applySyntheticProviderConfig(cfg: ClawdbotConfig): ClawdbotConfig { export function applySyntheticProviderConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const models = { ...cfg.agents?.defaults?.models }; const models = { ...cfg.agents?.defaults?.models };
models[SYNTHETIC_DEFAULT_MODEL_REF] = { models[SYNTHETIC_DEFAULT_MODEL_REF] = {
-13
View File
@@ -73,19 +73,6 @@ export async function setMoonshotApiKey(key: string, agentDir?: string) {
}); });
} }
export async function setKimiCodeApiKey(key: string, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "kimi-code:default",
credential: {
type: "api_key",
provider: "kimi-code",
key,
},
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setSyntheticApiKey(key: string, agentDir?: string) { export async function setSyntheticApiKey(key: string, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup. // Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({ upsertAuthProfile({
-27
View File
@@ -12,13 +12,6 @@ export const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview";
export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`; export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`;
export const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; export const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
export const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; export const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
export const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
export const KIMI_CODE_MODEL_ID = "kimi-for-coding";
export const KIMI_CODE_MODEL_REF = `kimi-code/${KIMI_CODE_MODEL_ID}`;
export const KIMI_CODE_CONTEXT_WINDOW = 262144;
export const KIMI_CODE_MAX_TOKENS = 32768;
export const KIMI_CODE_HEADERS = { "User-Agent": "KimiCLI/0.77" } as const;
export const KIMI_CODE_COMPAT = { supportsDeveloperRole: false } as const;
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs. // Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
export const MINIMAX_API_COST = { export const MINIMAX_API_COST = {
@@ -45,12 +38,6 @@ export const MOONSHOT_DEFAULT_COST = {
cacheRead: 0, cacheRead: 0,
cacheWrite: 0, cacheWrite: 0,
}; };
export const KIMI_CODE_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const MINIMAX_MODEL_CATALOG = { const MINIMAX_MODEL_CATALOG = {
"MiniMax-M2.1": { name: "MiniMax M2.1", reasoning: false }, "MiniMax-M2.1": { name: "MiniMax M2.1", reasoning: false },
@@ -102,17 +89,3 @@ export function buildMoonshotModelDefinition(): ModelDefinitionConfig {
maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS, maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS,
}; };
} }
export function buildKimiCodeModelDefinition(): ModelDefinitionConfig {
return {
id: KIMI_CODE_MODEL_ID,
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: KIMI_CODE_DEFAULT_COST,
contextWindow: KIMI_CODE_CONTEXT_WINDOW,
maxTokens: KIMI_CODE_MAX_TOKENS,
headers: KIMI_CODE_HEADERS,
compat: KIMI_CODE_COMPAT,
};
}
-7
View File
@@ -4,8 +4,6 @@ export {
} from "../agents/synthetic-models.js"; } from "../agents/synthetic-models.js";
export { export {
applyAuthProfileConfig, applyAuthProfileConfig,
applyKimiCodeConfig,
applyKimiCodeProviderConfig,
applyMoonshotConfig, applyMoonshotConfig,
applyMoonshotProviderConfig, applyMoonshotProviderConfig,
applyOpenrouterConfig, applyOpenrouterConfig,
@@ -33,7 +31,6 @@ export {
OPENROUTER_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF,
setAnthropicApiKey, setAnthropicApiKey,
setGeminiApiKey, setGeminiApiKey,
setKimiCodeApiKey,
setMinimaxApiKey, setMinimaxApiKey,
setMoonshotApiKey, setMoonshotApiKey,
setOpencodeZenApiKey, setOpencodeZenApiKey,
@@ -46,14 +43,10 @@ export {
ZAI_DEFAULT_MODEL_REF, ZAI_DEFAULT_MODEL_REF,
} from "./onboard-auth.credentials.js"; } from "./onboard-auth.credentials.js";
export { export {
buildKimiCodeModelDefinition,
buildMinimaxApiModelDefinition, buildMinimaxApiModelDefinition,
buildMinimaxModelDefinition, buildMinimaxModelDefinition,
buildMoonshotModelDefinition, buildMoonshotModelDefinition,
DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_BASE_URL,
KIMI_CODE_BASE_URL,
KIMI_CODE_MODEL_ID,
KIMI_CODE_MODEL_REF,
MINIMAX_API_BASE_URL, MINIMAX_API_BASE_URL,
MINIMAX_HOSTED_MODEL_ID, MINIMAX_HOSTED_MODEL_ID,
MINIMAX_HOSTED_MODEL_REF, MINIMAX_HOSTED_MODEL_REF,
@@ -13,7 +13,6 @@ import { buildTokenProfileId, validateAnthropicSetupToken } from "../../auth-tok
import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js"; import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js";
import { import {
applyAuthProfileConfig, applyAuthProfileConfig,
applyKimiCodeConfig,
applyMinimaxApiConfig, applyMinimaxApiConfig,
applyMinimaxConfig, applyMinimaxConfig,
applyMoonshotConfig, applyMoonshotConfig,
@@ -24,7 +23,6 @@ import {
applyZaiConfig, applyZaiConfig,
setAnthropicApiKey, setAnthropicApiKey,
setGeminiApiKey, setGeminiApiKey,
setKimiCodeApiKey,
setMinimaxApiKey, setMinimaxApiKey,
setMoonshotApiKey, setMoonshotApiKey,
setOpencodeZenApiKey, setOpencodeZenApiKey,
@@ -233,25 +231,6 @@ export async function applyNonInteractiveAuthChoice(params: {
return applyMoonshotConfig(nextConfig); return applyMoonshotConfig(nextConfig);
} }
if (authChoice === "kimi-code-api-key") {
const resolved = await resolveNonInteractiveApiKey({
provider: "kimi-code",
cfg: baseConfig,
flagValue: opts.kimiCodeApiKey,
flagName: "--kimi-code-api-key",
envVar: "KIMICODE_API_KEY",
runtime,
});
if (!resolved) return null;
if (resolved.source !== "profile") await setKimiCodeApiKey(resolved.key);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "kimi-code:default",
provider: "kimi-code",
mode: "api_key",
});
return applyKimiCodeConfig(nextConfig);
}
if (authChoice === "synthetic-api-key") { if (authChoice === "synthetic-api-key") {
const resolved = await resolveNonInteractiveApiKey({ const resolved = await resolveNonInteractiveApiKey({
provider: "synthetic", provider: "synthetic",
-2
View File
@@ -14,7 +14,6 @@ export type AuthChoice =
| "openrouter-api-key" | "openrouter-api-key"
| "ai-gateway-api-key" | "ai-gateway-api-key"
| "moonshot-api-key" | "moonshot-api-key"
| "kimi-code-api-key"
| "synthetic-api-key" | "synthetic-api-key"
| "codex-cli" | "codex-cli"
| "apiKey" | "apiKey"
@@ -58,7 +57,6 @@ export type OnboardOptions = {
openrouterApiKey?: string; openrouterApiKey?: string;
aiGatewayApiKey?: string; aiGatewayApiKey?: string;
moonshotApiKey?: string; moonshotApiKey?: string;
kimiCodeApiKey?: string;
geminiApiKey?: string; geminiApiKey?: string;
zaiApiKey?: string; zaiApiKey?: string;
minimaxApiKey?: string; minimaxApiKey?: string;
+70 -161
View File
@@ -163,35 +163,6 @@ function decodeDiscordCommandArgValue(value: string): string {
} }
} }
function isDiscordUnknownInteraction(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const err = error as {
discordCode?: number;
status?: number;
message?: string;
rawBody?: { code?: number; message?: string };
};
if (err.discordCode === 10062 || err.rawBody?.code === 10062) return true;
if (err.status === 404 && /Unknown interaction/i.test(err.message ?? "")) return true;
if (/Unknown interaction/i.test(err.rawBody?.message ?? "")) return true;
return false;
}
async function safeDiscordInteractionCall<T>(
label: string,
fn: () => Promise<T>,
): Promise<T | null> {
try {
return await fn();
} catch (error) {
if (isDiscordUnknownInteraction(error)) {
console.warn(`discord: ${label} skipped (interaction expired)`);
return null;
}
throw error;
}
}
function buildDiscordCommandArgCustomId(params: { function buildDiscordCommandArgCustomId(params: {
command: string; command: string;
arg: string; arg: string;
@@ -225,73 +196,6 @@ function parseDiscordCommandArgData(
}; };
} }
type DiscordCommandArgContext = {
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
};
async function handleDiscordCommandArgInteraction(
interaction: ButtonInteraction,
data: ComponentData,
ctx: DiscordCommandArgContext,
) {
const parsed = parseDiscordCommandArgData(data);
if (!parsed) {
await safeDiscordInteractionCall("command arg update", () =>
interaction.update({
content: "Sorry, that selection is no longer available.",
components: [],
}),
);
return;
}
if (interaction.user?.id && interaction.user.id !== parsed.userId) {
await safeDiscordInteractionCall("command arg ack", () => interaction.acknowledge());
return;
}
const commandDefinition =
findCommandByNativeName(parsed.command) ??
listChatCommands().find((entry) => entry.key === parsed.command);
if (!commandDefinition) {
await safeDiscordInteractionCall("command arg update", () =>
interaction.update({
content: "Sorry, that command is no longer available.",
components: [],
}),
);
return;
}
const updated = await safeDiscordInteractionCall("command arg update", () =>
interaction.update({
content: `✅ Selected ${parsed.value}.`,
components: [],
}),
);
if (!updated) return;
const commandArgs = createCommandArgsWithValue({
argName: parsed.arg,
value: parsed.value,
});
const commandArgsWithRaw: CommandArgs = {
...commandArgs,
raw: serializeCommandArgs(commandDefinition, commandArgs),
};
const prompt = buildCommandTextFromArgs(commandDefinition, commandArgsWithRaw);
await dispatchDiscordCommandInteraction({
interaction,
prompt,
command: commandDefinition,
commandArgs: commandArgsWithRaw,
cfg: ctx.cfg,
discordConfig: ctx.discordConfig,
accountId: ctx.accountId,
sessionPrefix: ctx.sessionPrefix,
preferFollowUp: true,
});
}
class DiscordCommandArgButton extends Button { class DiscordCommandArgButton extends Button {
label: string; label: string;
customId: string; customId: string;
@@ -319,34 +223,55 @@ class DiscordCommandArgButton extends Button {
} }
async run(interaction: ButtonInteraction, data: ComponentData) { async run(interaction: ButtonInteraction, data: ComponentData) {
await handleDiscordCommandArgInteraction(interaction, data, { const parsed = parseDiscordCommandArgData(data);
if (!parsed) {
await interaction.update({
content: "Sorry, that selection is no longer available.",
components: [],
});
return;
}
if (interaction.user?.id && interaction.user.id !== parsed.userId) {
await interaction.acknowledge();
return;
}
const commandDefinition =
findCommandByNativeName(parsed.command) ??
listChatCommands().find((entry) => entry.key === parsed.command);
if (!commandDefinition) {
await interaction.update({
content: "Sorry, that command is no longer available.",
components: [],
});
return;
}
await interaction.update({
content: `✅ Selected ${parsed.value}.`,
components: [],
});
const commandArgs = createCommandArgsWithValue({
argName: parsed.arg,
value: parsed.value,
});
const commandArgsWithRaw: CommandArgs = {
...commandArgs,
raw: serializeCommandArgs(commandDefinition, commandArgs),
};
const prompt = buildCommandTextFromArgs(commandDefinition, commandArgsWithRaw);
await dispatchDiscordCommandInteraction({
interaction,
prompt,
command: commandDefinition,
commandArgs: commandArgsWithRaw,
cfg: this.cfg, cfg: this.cfg,
discordConfig: this.discordConfig, discordConfig: this.discordConfig,
accountId: this.accountId, accountId: this.accountId,
sessionPrefix: this.sessionPrefix, sessionPrefix: this.sessionPrefix,
preferFollowUp: true,
}); });
} }
} }
class DiscordCommandArgFallbackButton extends Button {
label = "cmdarg";
customId = "cmdarg:seed=1";
private ctx: DiscordCommandArgContext;
constructor(ctx: DiscordCommandArgContext) {
super();
this.ctx = ctx;
}
async run(interaction: ButtonInteraction, data: ComponentData) {
await handleDiscordCommandArgInteraction(interaction, data, this.ctx);
}
}
export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgContext): Button {
return new DiscordCommandArgFallbackButton(params);
}
function buildDiscordCommandArgMenu(params: { function buildDiscordCommandArgMenu(params: {
command: ChatCommandDefinition; command: ChatCommandDefinition;
menu: { arg: CommandArgDefinition; choices: string[]; title?: string }; menu: { arg: CommandArgDefinition; choices: string[]; title?: string };
@@ -483,13 +408,11 @@ async function dispatchDiscordCommandInteraction(params: {
content, content,
...(options?.ephemeral !== undefined ? { ephemeral: options.ephemeral } : {}), ...(options?.ephemeral !== undefined ? { ephemeral: options.ephemeral } : {}),
}; };
await safeDiscordInteractionCall("interaction reply", async () => { if (preferFollowUp) {
if (preferFollowUp) { await interaction.followUp(payload);
await interaction.followUp(payload); return;
return; }
} await interaction.reply(payload);
await interaction.reply(payload);
});
}; };
const useAccessGroups = cfg.commands?.useAccessGroups !== false; const useAccessGroups = cfg.commands?.useAccessGroups !== false;
@@ -644,22 +567,18 @@ async function dispatchDiscordCommandInteraction(params: {
sessionPrefix, sessionPrefix,
}); });
if (preferFollowUp) { if (preferFollowUp) {
await safeDiscordInteractionCall("interaction follow-up", () => await interaction.followUp({
interaction.followUp({
content: menuPayload.content,
components: menuPayload.components,
ephemeral: true,
}),
);
return;
}
await safeDiscordInteractionCall("interaction reply", () =>
interaction.reply({
content: menuPayload.content, content: menuPayload.content,
components: menuPayload.components, components: menuPayload.components,
ephemeral: true, ephemeral: true,
}), });
); return;
}
await interaction.reply({
content: menuPayload.content,
components: menuPayload.components,
ephemeral: true,
});
return; return;
} }
@@ -727,23 +646,15 @@ async function dispatchDiscordCommandInteraction(params: {
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId), humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => { deliver: async (payload) => {
try { await deliverDiscordInteractionReply({
await deliverDiscordInteractionReply({ interaction,
interaction, payload,
payload, textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, { fallbackLimit: 2000,
fallbackLimit: 2000, }),
}), maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
maxLinesPerMessage: discordConfig?.maxLinesPerMessage, preferFollowUp: preferFollowUp || didReply,
preferFollowUp: preferFollowUp || didReply, });
});
} catch (error) {
if (isDiscordUnknownInteraction(error)) {
console.warn("discord: interaction reply skipped (interaction expired)");
return;
}
throw error;
}
didReply = true; didReply = true;
}, },
onError: (err, info) => { onError: (err, info) => {
@@ -786,15 +697,13 @@ async function deliverDiscordInteractionReply(params: {
}), }),
} }
: { content }; : { content };
await safeDiscordInteractionCall("interaction send", async () => { if (!preferFollowUp && !hasReplied) {
if (!preferFollowUp && !hasReplied) { await interaction.reply(payload);
await interaction.reply(payload);
hasReplied = true;
return;
}
await interaction.followUp(payload);
hasReplied = true; hasReplied = true;
}); return;
}
await interaction.followUp(payload);
hasReplied = true;
}; };
if (mediaList.length > 0) { if (mediaList.length > 0) {
+1 -12
View File
@@ -27,10 +27,7 @@ import {
registerDiscordListener, registerDiscordListener,
} from "./listeners.js"; } from "./listeners.js";
import { createDiscordMessageHandler } from "./message-handler.js"; import { createDiscordMessageHandler } from "./message-handler.js";
import { import { createDiscordNativeCommand } from "./native-command.js";
createDiscordCommandArgFallbackButton,
createDiscordNativeCommand,
} from "./native-command.js";
export type MonitorDiscordOpts = { export type MonitorDiscordOpts = {
token?: string; token?: string;
@@ -152,14 +149,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
{ {
commands, commands,
listeners: [], listeners: [],
components: [
createDiscordCommandArgFallbackButton({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
}),
],
}, },
[ [
new GatewayPlugin({ new GatewayPlugin({