Compare commits

...
Author SHA1 Message Date
Peter Steinberger ec9ba5b784 fix: show full gateway version string in status (#1111) (thanks @artuskg) 2026-01-17 17:45:14 +00:00
Artus KG cee4149884 macos: handle empty install version safely 2026-01-17 17:45:14 +00:00
Artus KG 5599e4cf35 test: make session update timestamp UTC 2026-01-17 17:45:14 +00:00
Artus KG 84cdd2df73 changelog: note CLI install build suffix fix 2026-01-17 17:45:14 +00:00
Artus KG 7929f57460 macos: keep CLI install build suffix 2026-01-17 17:45:04 +00:00
Peter Steinberger 7876679c5d style: apply oxfmt 2026-01-17 17:44:54 +00:00
Peter Steinberger bc6928525d docs: note discord interaction logging fix 2026-01-17 17:42:56 +00:00
Peter Steinberger 755c847d9a fix: soften discord interaction logging 2026-01-17 17:42:46 +00:00
Peter Steinberger 80a8639940 refactor: centralize telegram send param parsing 2026-01-17 17:36:37 +00:00
Peter Steinberger 6cb5704291 Merge pull request #1085 from dan-dr/chore/kimi-code-provider
Add Kimi Code provider onboarding
2026-01-17 17:36:30 +00:00
Peter Steinberger 4a987c836d fix: add Kimi Code docs + defaults (#1085) (thanks @dan-dr) 2026-01-17 17:35:40 +00:00
Peter Steinberger a2fb55326c Merge pull request #1099 from mukhtharcm/feat/message-tool-voice-support
feat(telegram): support sending audio as native voice notes via asVoice param in message tool
2026-01-17 17:33:20 +00:00
Peter Steinberger af29c6a980 fix: allow media-only telegram voice sends (#1099) (thanks @mukhtharcm) 2026-01-17 17:33:08 +00:00
Muhammed Mukhthar CM f2a0e8e5bb feat(telegram): support sending audio as native voice notes via asVoice param in message tool 2026-01-17 17:32:50 +00:00
Peter Steinberger f6456c2883 Merge pull request #1106 from gumadeiras/patch-2
Remove extra 'logging' page in the docs
2026-01-17 17:32:15 +00:00
Peter Steinberger 39f0d000d1 Merge pull request #1088 from sibbl/fix-matrix
feat(matrix): fix sending bug, add specific support for voice messages and images
2026-01-17 17:27:44 +00:00
Peter Steinberger a8d9d630bc fix: handle legacy matrix polls (#1088) (thanks @sibbl) 2026-01-17 17:27:12 +00:00
ddyo e93a1d8138 feat: add kimi code provider onboarding 2026-01-17 17:25:07 +00:00
Peter Steinberger f6681be6f4 style: tidy macOS config UI formatting 2026-01-17 17:22:42 +00:00
Peter Steinberger c79ac3fe81 test: cover semver suffix variants 2026-01-17 17:15:08 +00:00
Sebastian Schubotz b78b06353a feat(matrix): add specific voice message + image sending extending generic attachment sending 2026-01-17 17:12:38 +00:00
Sebastian Schubotz c49b6cc241 fix(matrix): fix sending being broken by normalizing thread ID normalization in message sending functions; improve matrix types 2026-01-17 17:12:38 +00:00
Peter Steinberger 30c945fe92 fix: cover semver patch suffix parsing (#1110) (thanks @zerone0x) 2026-01-17 16:50:05 +00:00
Peter Steinberger dfd511c310 Merge pull request #1110 from zerone0x/fix/issue-1107-semver-prerelease-suffix
fix(macos): parse semver patch correctly when version has prerelease suffix
2026-01-17 16:45:49 +00:00
Peter Steinberger 1657525201 chore: prep 2026.1.17 and onboard flow 2026-01-17 16:41:25 +00:00
zerone0xandClaude 3e4b0d0505 fix(macos): parse semver patch correctly when version has prerelease suffix
Strip prerelease (`-beta.1`) and build (`-4`) suffixes from the patch
component before parsing as integer. Previously `2026.1.11-4` parsed to
`patch: 0` because `Int("11-4")` returns nil; now correctly yields
`patch: 11`.

Fixes #1107

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-18 00:31:20 +08:00
Michael Behr 003c6c9ae1 add patches/.gitkeep (#1104) 2026-01-17 08:50:50 -06:00
Gustavo Madeira Santana 41fbb4d8b0 Remove extra 'logging' page in the docs
Removed 'logging' from the list of gateways.
2026-01-17 09:49:39 -05:00
Peter Steinberger eecb340f64 style: format update-cli 2026-01-17 12:51:08 +00:00
Peter Steinberger d029eaa0bb docs: tighten release preflight 2026-01-17 12:47:54 +00:00
Peter Steinberger 9c7dcc1ed7 chore: update appcast for 2026.1.16-2 2026-01-17 12:46:42 +00:00
52 changed files with 1032 additions and 270 deletions

No files matched your search

+1
View File
@@ -73,6 +73,7 @@
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable. - Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`. - Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
## Troubleshooting ## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`). - Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
+6
View File
@@ -2,6 +2,12 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.17 (Unreleased)
### Changes
- 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.
## 2026.1.16-2 ## 2026.1.16-2
### Changes ### Changes
+16 -16
View File
@@ -2,6 +2,22 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Clawdbot</title> <title>Clawdbot</title>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>6273</sparkle:version>
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
<h3>Changes</h3>
<ul>
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
</item>
<item> <item>
<title>2026.1.15</title> <title>2026.1.15</title>
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate> <pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
@@ -255,21 +271,5 @@
]]></description> ]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/> <enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item> </item>
<item>
<title>2026.1.12-2</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5534</sparkle:version>
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
<h3>Fixes</h3>
<ul>
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
</item>
</channel> </channel>
</rss> </rss>
@@ -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.expectedGatewayVersion()?.description ?? "latest" let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "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(schema, path: path) self.renderNode(self.schema, path: self.path)
} }
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView { private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
let storedValue = store.configValue(at: path) let storedValue = self.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 { $0.literalValue } let literals = nonNull.compactMap(\.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,15 +31,20 @@ struct ConfigSchemaForm: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) { Picker(
"",
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)
} })
)
} }
} }
@@ -71,8 +76,7 @@ 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":
@@ -80,8 +84,7 @@ 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":
@@ -93,8 +96,7 @@ struct ConfigSchemaForm: View {
Text("Unsupported field type.") Text("Unsupported field type.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} })
)
} }
} }
@@ -155,9 +157,7 @@ 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)
store.updateConfigValue(path: path, value: next) self.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("")
} }
store.updateConfigValue(path: path, value: next) self.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)
store.updateConfigValue(path: path, value: next) self.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
store.updateConfigValue(path: path, value: next) self.store.updateConfigValue(path: path, value: next)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@@ -270,9 +270,8 @@ struct ConfigSchemaForm: View {
}, },
set: { newValue in set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed) self.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> {
@@ -282,16 +281,15 @@ struct ConfigSchemaForm: View {
return defaultValue ?? false return defaultValue ?? false
}, },
set: { newValue in set: { newValue in
store.updateConfigValue(path: path, value: newValue) self.store.updateConfigValue(path: path, value: newValue)
} })
)
} }
private func numberBinding( private func numberBinding(
_ path: ConfigPath, _ path: ConfigPath,
isInteger: Bool, isInteger: Bool,
defaultValue: Double? defaultValue: Double?) -> Binding<String>
) -> 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) }
@@ -301,22 +299,21 @@ 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 {
store.updateConfigValue(path: path, value: nil) self.store.updateConfigValue(path: path, value: nil)
} else if let value = Double(trimmed) { } else if let value = Double(trimmed) {
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value) self.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? defaultValue: Any?) -> Binding<Int>
) -> Binding<Int> { {
Binding( Binding(
get: { get: {
let value = store.configValue(at: path) ?? defaultValue let value = self.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)
@@ -324,12 +321,11 @@ struct ConfigSchemaForm: View {
}, },
set: { index in set: { index in
guard index >= 0, index < options.count else { guard index >= 0, index < options.count else {
store.updateConfigValue(path: path, value: nil) self.store.updateConfigValue(path: path, value: nil)
return return
} }
store.updateConfigValue(path: path, value: options[index]) self.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> {
@@ -339,14 +335,13 @@ 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 = store.configValue(at: path) as? [String: Any] ?? [:] let current = self.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)
store.updateConfigValue(path: path, value: next) self.store.updateConfigValue(path: path, value: next)
} })
)
} }
} }
@@ -355,10 +350,10 @@ struct ChannelConfigForm: View {
let channelId: String let channelId: String
var body: some View { var body: some View {
if store.configSchemaLoading { if self.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: store, schema: schema, path: [.key("channels"), .key(channelId)]) ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.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": return "WhatsApp Web" case "whatsapp": "WhatsApp Web"
case "telegram": return "Telegram Bot" case "telegram": "Telegram Bot"
case "discord": return "Discord Bot" case "discord": "Discord Bot"
case "slack": return "Slack Bot" case "slack": "Slack Bot"
case "signal": return "Signal REST" case "signal": "Signal REST"
case "imessage": return "iMessage" case "imessage": "iMessage"
default: return self.resolveChannelTitle(id) default: self.resolveChannelTitle(id)
} }
} }
private func resolveChannelSystemImage(_ id: String) -> String { private func resolveChannelSystemImage(_ id: String) -> String {
switch id { switch id {
case "whatsapp": return "message" case "whatsapp": "message"
case "telegram": return "paperplane" case "telegram": "paperplane"
case "discord": return "bubble.left.and.bubble.right" case "discord": "bubble.left.and.bubble.right"
case "slack": return "number" case "slack": "number"
case "signal": return "antenna.radiowaves.left.and.right" case "signal": "antenna.radiowaves.left.and.right"
case "imessage": return "message.fill" case "imessage": "message.fill"
default: return "message" default: "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 .key(let key): case let .key(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 .index(let index): case let .index(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 .key(let key): case let .key(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 .index(let index): case let .index(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 .key(let key): case let .key(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 .key(let key): return key case let .key(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)
@@ -25,8 +25,10 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
let major = Int(parts[0]), let major = Int(parts[0]),
let minor = Int(parts[1]) let minor = Int(parts[1])
else { return nil } else { return nil }
let patch = Int(parts[2]) ?? 0 // Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
return Semver(major: major, minor: minor, patch: patch) let patchRaw = String(parts[2])
let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0
return Semver(major: major, minor: minor, patch: patchNumeric)
} }
func compatible(with required: Semver) -> Bool { func compatible(with required: Semver) -> Bool {
@@ -78,8 +80,13 @@ 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
return Semver.parse(bundleVersion) let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
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.
@@ -98,6 +105,7 @@ 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)
@@ -108,8 +116,8 @@ enum GatewayEnvironment {
kind: .missingNode, kind: .missingNode,
nodeVersion: nil, nodeVersion: nil,
gatewayVersion: nil, gatewayVersion: nil,
requiredGateway: expected?.description, requiredGateway: expectedString,
message: RuntimeLocator.describeFailure(err)) message: RuntimeLocator.describeFailure(err))
case let .success(runtime): case let .success(runtime):
let gatewayBin = CommandResolver.clawdbotExecutable() let gatewayBin = CommandResolver.clawdbotExecutable()
@@ -118,7 +126,7 @@ enum GatewayEnvironment {
kind: .missingGateway, kind: .missingGateway,
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: nil, gatewayVersion: nil,
requiredGateway: expected?.description, requiredGateway: expectedString,
message: "clawdbot CLI not found in PATH; install the CLI.") message: "clawdbot CLI not found in PATH; install the CLI.")
} }
@@ -126,13 +134,14 @@ 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: expected.description), kind: .incompatible(found: installed.description, required: expectedText),
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: installed.description, gatewayVersion: installed.description,
requiredGateway: expected.description, requiredGateway: expectedText,
message: """ message: """
Gateway version \(installed.description) is incompatible with app \(expected.description); Gateway version \(installed.description) is incompatible with app \(expectedText);
install or update the global package. install or update the global package.
""") """)
} }
@@ -150,7 +159,7 @@ enum GatewayEnvironment {
kind: .ok, kind: .ok,
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: gatewayVersionText, gatewayVersion: gatewayVersionText,
requiredGateway: expected?.description, requiredGateway: expectedString,
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)") message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
} }
} }
@@ -218,8 +227,18 @@ 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 target = version?.description ?? "latest" let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines)
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.expectedGatewayVersion()?.description ?? "latest" let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
return "npm install -g clawdbot@\(version)" return "npm install -g clawdbot@\(version)"
} }
@@ -6,9 +6,17 @@ import Testing
@Test func semverParsesCommonForms() { @Test func semverParsesCommonForms() {
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3)) #expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0)) #expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 0)) // patch drops trailing text #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("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() {
@@ -36,6 +44,7 @@ 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,6 +360,19 @@ 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
+2 -1
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|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|kimi-code-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,6 +302,7 @@ 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,6 +155,34 @@ 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:
-1
View File
@@ -894,7 +894,6 @@
"gateway/heartbeat", "gateway/heartbeat",
"gateway/doctor", "gateway/doctor",
"gateway/logging", "gateway/logging",
"logging",
"gateway/security", "gateway/security",
"gateway/sandbox-vs-tool-policy-vs-elevated", "gateway/sandbox-vs-tool-policy-vs-elevated",
"gateway/sandboxing", "gateway/sandboxing",
+43
View File
@@ -2234,6 +2234,49 @@ 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)](/providers/moonshot) - [Moonshot AI (Kimi + Kimi Code)](/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)](/providers/moonshot) - [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- [Synthetic](/providers/synthetic) - [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode) - [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai) - [Z.AI](/providers/zai)
+56 -6
View File
@@ -1,13 +1,16 @@
--- ---
summary: "Use Moonshot AI (Kimi K2) with Clawdbot" summary: "Configure Moonshot K2 vs Kimi Code (separate providers + keys)"
read_when: read_when:
- You want to use Moonshot/Kimi models in Clawdbot - You want Moonshot K2 (Moonshot Open Platform) vs Kimi Code setup
- You need the Moonshot auth + config example - You need to understand separate endpoints, keys, and model refs
- 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`. provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use
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 */}
@@ -21,7 +24,15 @@ Current Kimi K2 model IDs:
clawdbot onboard --auth-choice moonshot-api-key clawdbot onboard --auth-choice moonshot-api-key
``` ```
## Config snippet Kimi Code:
```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
{ {
@@ -92,9 +103,48 @@ clawdbot onboard --auth-choice moonshot-api-key
} }
``` ```
## 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
- Model refs use `moonshot/<modelId>`. - Moonshot model refs use `moonshot/<modelId>`. Kimi Code model refs use `kimi-code/<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.
+6
View File
@@ -10,6 +10,12 @@ read_when:
Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing. Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing.
## Operator trigger
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
- Read this doc and `docs/platforms/mac/release.md`.
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
1) **Version & metadata** 1) **Version & metadata**
- [ ] Bump `package.json` version (e.g., `1.1.0`). - [ ] Bump `package.json` version (e.g., `1.1.0`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs. - [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
+2 -1
View File
@@ -95,7 +95,8 @@ 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.
- More detail: [Moonshot AI](/providers/moonshot) - **Kimi Code**: config is auto-written.
- 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 } from "../../../../../src/globals.js"; import { danger, logVerbose } 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,7 +18,12 @@ 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) {
if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) { const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 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;
} }
@@ -57,6 +62,7 @@ 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;
@@ -0,0 +1,22 @@
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"]);
});
});
+35 -28
View File
@@ -7,15 +7,17 @@
* - 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"; export const M_POLL_START = "m.poll.start" as const;
export const M_POLL_RESPONSE = "m.poll.response"; export const M_POLL_RESPONSE = "m.poll.response" as const;
export const M_POLL_END = "m.poll.end"; export const M_POLL_END = "m.poll.end" as const;
export const ORG_POLL_START = "org.matrix.msc3381.poll.start"; export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const;
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response"; export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const;
export const ORG_POLL_END = "org.matrix.msc3381.poll.end"; export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const;
export const POLL_EVENT_TYPES = [ export const POLL_EVENT_TYPES = [
M_POLL_START, M_POLL_START,
@@ -32,9 +34,7 @@ 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 = { export type TextContent = ExtensibleAnyMessageEventContent & {
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
body?: string; body?: string;
}; };
@@ -42,25 +42,19 @@ export type PollAnswer = {
id: string; id: string;
} & TextContent; } & TextContent;
export type PollStartContent = { export type PollStartSubtype = {
"m.poll"?: { question: TextContent;
question: TextContent; kind?: PollKind;
kind?: PollKind; max_selections?: number;
max_selections?: number; answers: PollAnswer[];
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;
@@ -82,7 +76,9 @@ export function getTextContent(text?: TextContent): string {
} }
export function parsePollStartContent(content: PollStartContent): PollSummary | null { export function parsePollStartContent(content: PollStartContent): PollSummary | null {
const poll = content["m.poll"] ?? content["org.matrix.msc3381.poll.start"]; const poll = (content as Record<string, PollStartSubtype | undefined>)[M_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);
@@ -121,6 +117,11 @@ 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
@@ -132,13 +133,19 @@ 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": { [M_POLL_START]: {
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,
}; };
} }
+7 -2
View File
@@ -31,6 +31,11 @@ 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 = () => {
@@ -65,13 +70,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]?.[2] as { const content = sendMessage.mock.calls[0]?.[1] as {
url?: string; url?: string;
msgtype?: string; msgtype?: string;
format?: string; format?: string;
formatted_body?: string; formatted_body?: string;
}; };
expect(content.msgtype).toBe("m.file"); expect(content.msgtype).toBe("m.image");
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");
+145 -22
View File
@@ -1,12 +1,15 @@
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 {
ReactionEventContent,
RoomMessageEventContent, RoomMessageEventContent,
ReactionEventContent,
} 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";
@@ -47,6 +50,8 @@ 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() {
@@ -71,6 +76,12 @@ 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("@")) {
@@ -119,6 +130,18 @@ 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;
@@ -127,8 +150,24 @@ 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 = { mimetype: params.mimetype, size: params.size }; const info: Record<string, unknown> = { 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,
@@ -136,6 +175,12 @@ 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;
} }
@@ -171,6 +216,75 @@ 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,
@@ -238,14 +352,10 @@ 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 rawThreadId = opts.threadId; const threadId = normalizeThreadId(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) =>
client.sendMessage(roomId, threadId ?? undefined, content); threadId ? client.sendMessage(roomId, threadId, content) : client.sendMessage(roomId, content);
let lastMessageId = ""; let lastMessageId = "";
if (opts.mediaUrl) { if (opts.mediaUrl) {
@@ -255,9 +365,17 @@ export async function sendMessageMatrix(
contentType: media.contentType, contentType: media.contentType,
filename: media.fileName, filename: media.fileName,
}); });
const msgtype = MsgType.File; const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
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 = firstChunk ?? media.fileName ?? "(file)"; const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
const content = buildMediaContent({ const content = buildMediaContent({
msgtype, msgtype,
body, body,
@@ -266,10 +384,13 @@ 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;
for (const chunk of rest) { const textChunks = useVoice ? chunks : 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);
@@ -316,17 +437,19 @@ 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 rawThreadId = opts.threadId; const threadId = normalizeThreadId(opts.threadId);
const threadId = const response = threadId
rawThreadId !== undefined && rawThreadId !== null ? await client.sendEvent(
? String(rawThreadId).trim() roomId,
: null; threadId,
const response = await client.sendEvent( M_POLL_START,
roomId, pollContent,
threadId ?? undefined, )
M_POLL_START as EventType.RoomMessage, : await client.sendEvent(
pollContent as unknown as RoomMessageEventContent, roomId,
); M_POLL_START,
pollContent,
);
return { return {
eventId: response.event_id ?? "unknown", eventId: response.event_id ?? "unknown",
View File
Whitespace-only changes.
+6
View File
@@ -232,6 +232,12 @@ importers:
specifier: 3.14.5 specifier: 3.14.5
version: 3.14.5(typescript@5.9.3) version: 3.14.5(typescript@5.9.3)
extensions/copilot-proxy: {}
extensions/google-antigravity-auth: {}
extensions/google-gemini-cli-auth: {}
extensions/matrix: extensions/matrix:
dependencies: dependencies:
markdown-it: markdown-it:
+26 -15
View File
@@ -168,24 +168,33 @@ TRASH
fi fi
} }
select_skip_hooks() {
# Hooks multiselect: pick "Skip for now".
wait_for_log "Enable hooks?" 60 || true
send $'"'"' \r'"'"' 0.6
}
send_local_basic() { send_local_basic() {
# Risk acknowledgement (default is "No"). # Risk acknowledgement (default is "No").
send $'"'"'y\r'"'"' 0.6 send $'"'"'y\r'"'"' 0.6
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI. # Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
send $'"'"'\r'"'"' 0.5 send $'"'"'\r'"'"' 0.5
select_skip_hooks
} }
send_reset_config_only() { send_reset_config_only() {
# Risk acknowledgement (default is "No"). # Risk acknowledgement (default is "No").
send $'"'"'y\r'"'"' 0.8 send $'"'"'y\r'"'"' 0.8
# Reset config + reuse the local defaults flow. # Select reset flow for existing config.
send $'"'"'\e[B'"'"' 0.3 wait_for_log "Config handling" 40 || true
send $'"'"'\e[B'"'"' 0.3 send $'"'"'\e[B'"'"' 0.3
send $'"'"'\r'"'"' 0.4 send $'"'"'\e[B'"'"' 0.3
send $'"'"'\r'"'"' 0.4 send $'"'"'\r'"'"' 0.4
send "" 1.2 # Reset scope -> Config only (default).
send_local_basic wait_for_log "Reset scope" 40 || true
} send $'"'"'\r'"'"' 0.4
select_skip_hooks
}
send_channels_flow() { send_channels_flow() {
# Configure channels via configure wizard. # Configure channels via configure wizard.
@@ -202,11 +211,13 @@ TRASH
send_skills_flow() { send_skills_flow() {
# Select skills section and skip optional installs. # Select skills section and skip optional installs.
send $'"'"'\r'"'"' 1.2 wait_for_log "Where will the Gateway run?" 40 || true
send "" 1.0 send $'"'"'\r'"'"' 0.8
# Configure skills now? -> No # Configure skills now? -> No
send $'"'"'n\r'"'"' 1.2 wait_for_log "Configure skills now?" 40 || true
send "" 2.0 send $'"'"'n\r'"'"' 0.8
wait_for_log "Configure complete." 40 || true
send "" 0.8
} }
run_case_local_basic() { run_case_local_basic() {
+1
View File
@@ -157,6 +157,7 @@ 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,6 +37,18 @@ 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();
@@ -184,6 +196,26 @@ 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,
@@ -212,6 +244,13 @@ 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,6 +39,7 @@ 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,6 +175,7 @@ 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:17"); const timestamp = new Date("2026-01-12T20:19:17Z");
vi.setSystemTime(timestamp); vi.setSystemTime(timestamp);
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" }); enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
@@ -0,0 +1,39 @@
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,
);
});
});
+25 -15
View File
@@ -10,6 +10,29 @@ 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(
@@ -41,25 +64,12 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
}, },
handleAction: async ({ action, params, cfg, accountId }) => { handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "send") { if (action === "send") {
const to = readStringParam(params, "to", { required: true }); const sendParams = readTelegramSendParams(params);
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",
to, ...sendParams,
content,
mediaUrl: mediaUrl ?? undefined,
replyToMessageId: replyTo ?? undefined,
messageThreadId: threadId ?? undefined,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
buttons,
}, },
cfg, cfg,
); );
+23
View File
@@ -180,6 +180,29 @@ 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(
+3 -1
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|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|kimi-code-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,6 +68,7 @@ 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")
@@ -116,6 +117,7 @@ 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 -3
View File
@@ -247,9 +247,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
} }
if (opts.channel && !configSnapshot.valid) { if (opts.channel && !configSnapshot.valid) {
const issues = configSnapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`); const issues = configSnapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`);
defaultRuntime.error( defaultRuntime.error(["Config is invalid; cannot set update channel.", ...issues].join("\n"));
["Config is invalid; cannot set update channel.", ...issues].join("\n"),
);
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
+1
View File
@@ -100,6 +100,7 @@ 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", () => {
+3 -2
View File
@@ -79,8 +79,8 @@ const AUTH_CHOICE_GROUP_DEFS: {
{ {
value: "moonshot", value: "moonshot",
label: "Moonshot AI", label: "Moonshot AI",
hint: "Kimi K2 preview", hint: "Kimi K2 + Kimi Code",
choices: ["moonshot-api-key"], choices: ["moonshot-api-key", "kimi-code-api-key"],
}, },
{ {
value: "zai", value: "zai",
@@ -180,6 +180,7 @@ 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,6 +13,8 @@ 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,
@@ -24,11 +26,13 @@ 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,
@@ -208,6 +212,55 @@ 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,6 +13,7 @@ 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,7 +11,11 @@ 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,
@@ -192,6 +196,71 @@ 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,6 +73,19 @@ 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,6 +12,13 @@ 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 = {
@@ -38,6 +45,12 @@ 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 },
@@ -89,3 +102,17 @@ 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,6 +4,8 @@ export {
} from "../agents/synthetic-models.js"; } from "../agents/synthetic-models.js";
export { export {
applyAuthProfileConfig, applyAuthProfileConfig,
applyKimiCodeConfig,
applyKimiCodeProviderConfig,
applyMoonshotConfig, applyMoonshotConfig,
applyMoonshotProviderConfig, applyMoonshotProviderConfig,
applyOpenrouterConfig, applyOpenrouterConfig,
@@ -31,6 +33,7 @@ export {
OPENROUTER_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF,
setAnthropicApiKey, setAnthropicApiKey,
setGeminiApiKey, setGeminiApiKey,
setKimiCodeApiKey,
setMinimaxApiKey, setMinimaxApiKey,
setMoonshotApiKey, setMoonshotApiKey,
setOpencodeZenApiKey, setOpencodeZenApiKey,
@@ -43,10 +46,14 @@ 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,6 +13,7 @@ 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,
@@ -23,6 +24,7 @@ import {
applyZaiConfig, applyZaiConfig,
setAnthropicApiKey, setAnthropicApiKey,
setGeminiApiKey, setGeminiApiKey,
setKimiCodeApiKey,
setMinimaxApiKey, setMinimaxApiKey,
setMoonshotApiKey, setMoonshotApiKey,
setOpencodeZenApiKey, setOpencodeZenApiKey,
@@ -231,6 +233,25 @@ 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,6 +14,7 @@ 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"
@@ -57,6 +58,7 @@ 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;
+161 -70
View File
@@ -163,6 +163,35 @@ 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;
@@ -196,6 +225,73 @@ 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;
@@ -223,55 +319,34 @@ class DiscordCommandArgButton extends Button {
} }
async run(interaction: ButtonInteraction, data: ComponentData) { async run(interaction: ButtonInteraction, data: ComponentData) {
const parsed = parseDiscordCommandArgData(data); await handleDiscordCommandArgInteraction(interaction, 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 };
@@ -408,11 +483,13 @@ async function dispatchDiscordCommandInteraction(params: {
content, content,
...(options?.ephemeral !== undefined ? { ephemeral: options.ephemeral } : {}), ...(options?.ephemeral !== undefined ? { ephemeral: options.ephemeral } : {}),
}; };
if (preferFollowUp) { await safeDiscordInteractionCall("interaction reply", async () => {
await interaction.followUp(payload); if (preferFollowUp) {
return; await interaction.followUp(payload);
} return;
await interaction.reply(payload); }
await interaction.reply(payload);
});
}; };
const useAccessGroups = cfg.commands?.useAccessGroups !== false; const useAccessGroups = cfg.commands?.useAccessGroups !== false;
@@ -567,18 +644,22 @@ async function dispatchDiscordCommandInteraction(params: {
sessionPrefix, sessionPrefix,
}); });
if (preferFollowUp) { if (preferFollowUp) {
await interaction.followUp({ await safeDiscordInteractionCall("interaction follow-up", () =>
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;
} }
@@ -646,15 +727,23 @@ 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) => {
await deliverDiscordInteractionReply({ try {
interaction, await deliverDiscordInteractionReply({
payload, interaction,
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, { payload,
fallbackLimit: 2000, textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
}), fallbackLimit: 2000,
maxLinesPerMessage: discordConfig?.maxLinesPerMessage, }),
preferFollowUp: preferFollowUp || didReply, maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
}); 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) => {
@@ -697,13 +786,15 @@ async function deliverDiscordInteractionReply(params: {
}), }),
} }
: { content }; : { content };
if (!preferFollowUp && !hasReplied) { await safeDiscordInteractionCall("interaction send", async () => {
await interaction.reply(payload); if (!preferFollowUp && !hasReplied) {
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) {
+12 -1
View File
@@ -27,7 +27,10 @@ import {
registerDiscordListener, registerDiscordListener,
} from "./listeners.js"; } from "./listeners.js";
import { createDiscordMessageHandler } from "./message-handler.js"; import { createDiscordMessageHandler } from "./message-handler.js";
import { createDiscordNativeCommand } from "./native-command.js"; import {
createDiscordCommandArgFallbackButton,
createDiscordNativeCommand,
} from "./native-command.js";
export type MonitorDiscordOpts = { export type MonitorDiscordOpts = {
token?: string; token?: string;
@@ -149,6 +152,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
{ {
commands, commands,
listeners: [], listeners: [],
components: [
createDiscordCommandArgFallbackButton({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
}),
],
}, },
[ [
new GatewayPlugin({ new GatewayPlugin({