typeclaw 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/channels/adapters/discord-bot.ts +8 -1
- package/src/channels/adapters/kakaotalk-format.ts +239 -0
- package/src/channels/adapters/kakaotalk.ts +54 -5
- package/src/channels/adapters/telegram-bot.ts +11 -1
- package/src/channels/router.ts +131 -18
- package/src/channels/types.ts +22 -0
- package/src/config/providers.ts +17 -4
- package/src/container/start.ts +17 -0
- package/src/init/dockerfile.ts +21 -1
- package/src/sandbox/availability.ts +35 -0
- package/src/sandbox/build.ts +128 -0
- package/src/sandbox/errors.ts +20 -0
- package/src/sandbox/index.ts +14 -0
- package/src/sandbox/policy.ts +47 -0
- package/src/sandbox/quote.ts +18 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +11 -9
- package/typeclaw.schema.json +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@mariozechner/pi-coding-agent": "^0.67.3",
|
|
47
47
|
"@mariozechner/pi-tui": "^0.67.3",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"agent-messenger": "2.
|
|
49
|
+
"agent-messenger": "2.19.0",
|
|
50
50
|
"cheerio": "^1.2.0",
|
|
51
51
|
"citty": "^0.2.2",
|
|
52
52
|
"cron-parser": "^5.5.0",
|
|
@@ -393,7 +393,14 @@ export function createOutboundCallback(deps: {
|
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
try {
|
|
396
|
-
const
|
|
396
|
+
const sendOptions: { thread_id?: string; reply_to?: string } = {}
|
|
397
|
+
if (msg.thread) sendOptions.thread_id = msg.thread
|
|
398
|
+
if (msg.replyTo?.externalMessageId) sendOptions.reply_to = msg.replyTo.externalMessageId
|
|
399
|
+
const sent = await client.sendMessage(
|
|
400
|
+
msg.chat,
|
|
401
|
+
text,
|
|
402
|
+
Object.keys(sendOptions).length > 0 ? sendOptions : undefined,
|
|
403
|
+
)
|
|
397
404
|
logger.info(`[discord-bot] sent id=${sent.id} ${tag}`)
|
|
398
405
|
return { ok: true }
|
|
399
406
|
} catch (err) {
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// KakaoTalk's LOCO protocol renders no rich text — bytes display verbatim, so
|
|
2
|
+
// the agent's Markdown (`**bold**`, `### heading`, fenced ```blocks```) leaks
|
|
3
|
+
// literal `*`/`#`/backtick noise into the chat. This strips the formatting
|
|
4
|
+
// markers and keeps the content. Mirrors telegram-bot-format.ts, but emits
|
|
5
|
+
// plain content instead of re-encoding to MarkdownV2. Links collapse to
|
|
6
|
+
// `label (url)` so the destination survives; list/quote markers stay (they
|
|
7
|
+
// read fine unrendered).
|
|
8
|
+
|
|
9
|
+
export function toKakaoPlainText(input: string): string {
|
|
10
|
+
// Pull fenced code out first so a `*` inside a block is not re-tokenized as
|
|
11
|
+
// italic.
|
|
12
|
+
const out: string[] = []
|
|
13
|
+
let i = 0
|
|
14
|
+
while (i < input.length) {
|
|
15
|
+
if (matchesAt(input, i, '```')) {
|
|
16
|
+
const fenceEnd = findFenceEnd(input, i + 3)
|
|
17
|
+
if (fenceEnd !== -1) {
|
|
18
|
+
out.push(renderFence(input.slice(i + 3, fenceEnd)))
|
|
19
|
+
i = fenceEnd + 3
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
// Unterminated fence — strip the open backticks and render the rest
|
|
23
|
+
// inline so we never infinite-loop and never drop the tail.
|
|
24
|
+
out.push(renderInline(stripLeadingFence(input.slice(i + 3))))
|
|
25
|
+
break
|
|
26
|
+
}
|
|
27
|
+
const nextFence = input.indexOf('```', i)
|
|
28
|
+
const segmentEnd = nextFence === -1 ? input.length : nextFence
|
|
29
|
+
out.push(renderLines(input.slice(i, segmentEnd)))
|
|
30
|
+
i = segmentEnd
|
|
31
|
+
}
|
|
32
|
+
return out.join('')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function matchesAt(s: string, idx: number, needle: string): boolean {
|
|
36
|
+
return s.slice(idx, idx + needle.length) === needle
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findFenceEnd(s: string, start: number): number {
|
|
40
|
+
return s.indexOf('```', start)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripLeadingFence(inner: string): string {
|
|
44
|
+
// Drop an optional language hint and the newline after an opening fence.
|
|
45
|
+
const newline = inner.indexOf('\n')
|
|
46
|
+
if (newline === -1) return inner
|
|
47
|
+
const candidate = inner.slice(0, newline).trim()
|
|
48
|
+
if (candidate === '' || /^[A-Za-z0-9_+\-.]+$/.test(candidate)) {
|
|
49
|
+
return inner.slice(newline + 1)
|
|
50
|
+
}
|
|
51
|
+
return inner
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderFence(inner: string): string {
|
|
55
|
+
// Keep the code body verbatim, drop the fences and any language hint.
|
|
56
|
+
let body = inner
|
|
57
|
+
const newline = inner.indexOf('\n')
|
|
58
|
+
if (newline !== -1) {
|
|
59
|
+
const candidate = inner.slice(0, newline).trim()
|
|
60
|
+
if (candidate === '' || /^[A-Za-z0-9_+\-.]+$/.test(candidate)) {
|
|
61
|
+
body = inner.slice(newline + 1)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (body.endsWith('\n')) body = body.slice(0, -1)
|
|
65
|
+
return body
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Strip per-line block markers (heading hashes, blockquote arrows) before
|
|
69
|
+
// running the inline tokenizer on each line. List markers (`- `, `* `, `1.`)
|
|
70
|
+
// are left intact — they read fine as plain text and signal structure.
|
|
71
|
+
function renderLines(text: string): string {
|
|
72
|
+
const lines = text.split('\n')
|
|
73
|
+
const rendered = lines.map((line) => renderInline(stripBlockMarkers(line)))
|
|
74
|
+
return rendered.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stripBlockMarkers(line: string): string {
|
|
78
|
+
// `### heading` → `heading`; `> quote` → `quote`. Only acts on leading
|
|
79
|
+
// markers after optional indentation so mid-line `#`/`>` stay literal.
|
|
80
|
+
const heading = /^(\s*)#{1,6}\s+(.*)$/.exec(line)
|
|
81
|
+
if (heading !== null) return heading[1]! + heading[2]!
|
|
82
|
+
const quote = /^(\s*)>\s?(.*)$/.exec(line)
|
|
83
|
+
if (quote !== null) return quote[1]! + quote[2]!
|
|
84
|
+
return line
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Inline tokenizer. Recognizes (in priority order):
|
|
88
|
+
// 1. Inline code: `code` → code
|
|
89
|
+
// 2. Links: [text](url) → text (url)
|
|
90
|
+
// 3. Bold: **text** / __text__ → text
|
|
91
|
+
// 4. Strikethrough: ~~text~~ → text
|
|
92
|
+
// 5. Italic: *text* / _text_ → text
|
|
93
|
+
//
|
|
94
|
+
// Bold is checked before italic so `**` is not eaten as two italic markers.
|
|
95
|
+
// Word-boundary guards keep snake_case identifiers and `a*b` math from being
|
|
96
|
+
// mistaken for emphasis — the same rules the Telegram formatter uses.
|
|
97
|
+
function renderInline(text: string): string {
|
|
98
|
+
const out: string[] = []
|
|
99
|
+
let i = 0
|
|
100
|
+
while (i < text.length) {
|
|
101
|
+
const ch = text[i]!
|
|
102
|
+
|
|
103
|
+
if (ch === '`') {
|
|
104
|
+
const close = text.indexOf('`', i + 1)
|
|
105
|
+
if (close !== -1) {
|
|
106
|
+
out.push(text.slice(i + 1, close))
|
|
107
|
+
i = close + 1
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (ch === '[') {
|
|
113
|
+
const link = parseLink(text, i)
|
|
114
|
+
if (link !== null) {
|
|
115
|
+
const label = renderInline(link.label)
|
|
116
|
+
out.push(link.url === '' ? label : `${label} (${link.url})`)
|
|
117
|
+
i = link.end
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (ch === '*' && text[i + 1] === '*') {
|
|
123
|
+
const close = findClose(text, i + 2, '**')
|
|
124
|
+
if (close !== -1 && close > i + 2) {
|
|
125
|
+
out.push(renderInline(text.slice(i + 2, close)))
|
|
126
|
+
i = close + 2
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (ch === '_' && text[i + 1] === '_' && !isWordChar(text[i - 1])) {
|
|
131
|
+
const close = findClose(text, i + 2, '__')
|
|
132
|
+
if (close !== -1 && close > i + 2 && !isWordChar(text[close + 2])) {
|
|
133
|
+
out.push(renderInline(text.slice(i + 2, close)))
|
|
134
|
+
i = close + 2
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (ch === '~' && text[i + 1] === '~') {
|
|
140
|
+
const close = findClose(text, i + 2, '~~')
|
|
141
|
+
if (close !== -1 && close > i + 2) {
|
|
142
|
+
out.push(renderInline(text.slice(i + 2, close)))
|
|
143
|
+
i = close + 2
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (ch === '*' && !isWordChar(text[i - 1])) {
|
|
149
|
+
const close = findInlineClose(text, i + 1, '*')
|
|
150
|
+
if (close !== -1 && !isWordChar(text[close + 1])) {
|
|
151
|
+
const inner = text.slice(i + 1, close)
|
|
152
|
+
if (inner !== '' && !/^\s|\s$/.test(inner)) {
|
|
153
|
+
out.push(renderInline(inner))
|
|
154
|
+
i = close + 1
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (ch === '_' && !isWordChar(text[i - 1])) {
|
|
160
|
+
const close = findInlineClose(text, i + 1, '_')
|
|
161
|
+
if (close !== -1 && !isWordChar(text[close + 1])) {
|
|
162
|
+
const inner = text.slice(i + 1, close)
|
|
163
|
+
if (inner !== '' && !/^\s|\s$/.test(inner)) {
|
|
164
|
+
out.push(renderInline(inner))
|
|
165
|
+
i = close + 1
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
out.push(ch)
|
|
172
|
+
i++
|
|
173
|
+
}
|
|
174
|
+
return out.join('')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function findClose(text: string, from: number, marker: string): number {
|
|
178
|
+
let i = from
|
|
179
|
+
while (i <= text.length - marker.length) {
|
|
180
|
+
if (text[i] === '\\') {
|
|
181
|
+
i += 2
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (matchesAt(text, i, marker)) return i
|
|
185
|
+
i++
|
|
186
|
+
}
|
|
187
|
+
return -1
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function findInlineClose(text: string, from: number, marker: string): number {
|
|
191
|
+
let i = from
|
|
192
|
+
while (i < text.length) {
|
|
193
|
+
if (text[i] === '\n') return -1
|
|
194
|
+
if (text[i] === '\\') {
|
|
195
|
+
i += 2
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (matchesAt(text, i, marker)) return i
|
|
199
|
+
i++
|
|
200
|
+
}
|
|
201
|
+
return -1
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseLink(text: string, start: number): { label: string; url: string; end: number } | null {
|
|
205
|
+
let i = start + 1
|
|
206
|
+
const labelStart = i
|
|
207
|
+
while (i < text.length) {
|
|
208
|
+
const c = text[i]!
|
|
209
|
+
if (c === '\\') {
|
|
210
|
+
i += 2
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (c === ']') break
|
|
214
|
+
if (c === '\n') return null
|
|
215
|
+
i++
|
|
216
|
+
}
|
|
217
|
+
if (text[i] !== ']' || text[i + 1] !== '(') return null
|
|
218
|
+
const label = text.slice(labelStart, i)
|
|
219
|
+
const urlStart = i + 2
|
|
220
|
+
let j = urlStart
|
|
221
|
+
while (j < text.length) {
|
|
222
|
+
const c = text[j]!
|
|
223
|
+
if (c === '\\') {
|
|
224
|
+
j += 2
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
if (c === ')') break
|
|
228
|
+
if (c === '(') return null
|
|
229
|
+
if (c === '\n') return null
|
|
230
|
+
j++
|
|
231
|
+
}
|
|
232
|
+
if (text[j] !== ')') return null
|
|
233
|
+
return { label, url: text.slice(urlStart, j), end: j + 1 }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isWordChar(ch: string | undefined): boolean {
|
|
237
|
+
if (ch === undefined) return false
|
|
238
|
+
return /[A-Za-z0-9_]/.test(ch)
|
|
239
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type KakaoMember,
|
|
9
9
|
type KakaoMessage,
|
|
10
10
|
type KakaoProfile,
|
|
11
|
+
type KakaoReplyTarget,
|
|
11
12
|
type KakaoSendResult,
|
|
12
13
|
type KakaoTalkListenerEventMap,
|
|
13
14
|
type KakaoTalkPushEmoticonEvent,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
} from 'agent-messenger/kakaotalk'
|
|
16
17
|
import type { KakaoAccountCredentials, KakaoConfig, PendingLoginState } from 'agent-messenger/kakaotalk'
|
|
17
18
|
|
|
18
|
-
import type
|
|
19
|
+
import { prependQuoteAnchor, type ChannelRouter } from '@/channels/router'
|
|
19
20
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
20
21
|
import type {
|
|
21
22
|
ChannelHistoryMessage,
|
|
@@ -39,6 +40,7 @@ import { createKakaoAuthorResolver, type KakaoAuthorResolver } from './kakaotalk
|
|
|
39
40
|
import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaotalk-channel-resolver'
|
|
40
41
|
import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
|
|
41
42
|
import { createFetchAttachmentCallback } from './kakaotalk-fetch-attachment'
|
|
43
|
+
import { toKakaoPlainText } from './kakaotalk-format'
|
|
42
44
|
|
|
43
45
|
// Structural duck-type of the upstream KakaoTalkClient class. The upstream
|
|
44
46
|
// type is a class with private fields, and TypeScript treats those
|
|
@@ -53,7 +55,7 @@ export interface KakaoTalkClient {
|
|
|
53
55
|
): Promise<this>
|
|
54
56
|
getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]>
|
|
55
57
|
getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]>
|
|
56
|
-
sendMessage(chatId: string, text: string): Promise<KakaoSendResult>
|
|
58
|
+
sendMessage(chatId: string, text: string, options?: { replyTo?: KakaoReplyTarget }): Promise<KakaoSendResult>
|
|
57
59
|
sendAttachment(
|
|
58
60
|
chatId: string,
|
|
59
61
|
data: Uint8Array | Buffer,
|
|
@@ -160,6 +162,11 @@ export type KakaotalkAdapter = {
|
|
|
160
162
|
|
|
161
163
|
export const KAKAO_HISTORY_LIMIT_MAX = 200
|
|
162
164
|
|
|
165
|
+
// How far back to scan for a reply target's source message. Matches the upstream
|
|
166
|
+
// CLI's window; an anchored reply targets the message just answered, so the
|
|
167
|
+
// target is almost always near the head of this window.
|
|
168
|
+
const KAKAO_REPLY_LOOKUP_COUNT = 100
|
|
169
|
+
|
|
163
170
|
function formatLabel(name: string | undefined, id: string, prefix = ''): string {
|
|
164
171
|
if (name === undefined || name === '' || name === id) return id
|
|
165
172
|
return `${prefix}${name}(${id})`
|
|
@@ -171,7 +178,7 @@ async function readAttachmentBuffer(path: string): Promise<Buffer> {
|
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
export function createOutboundCallback(deps: {
|
|
174
|
-
client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment'>
|
|
181
|
+
client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment' | 'getMessages'>
|
|
175
182
|
logger: KakaotalkAdapterLogger
|
|
176
183
|
formatChannelTag: (workspace: string, chat: string) => Promise<string>
|
|
177
184
|
readFile?: (path: string) => Promise<Buffer>
|
|
@@ -182,7 +189,7 @@ export function createOutboundCallback(deps: {
|
|
|
182
189
|
if (msg.adapter !== 'kakaotalk') {
|
|
183
190
|
return { ok: false, error: `unknown adapter: ${msg.adapter}` }
|
|
184
191
|
}
|
|
185
|
-
const text = msg.text ?? ''
|
|
192
|
+
const text = toKakaoPlainText(msg.text ?? '')
|
|
186
193
|
const attachments = msg.attachments ?? []
|
|
187
194
|
if (text === '' && attachments.length === 0) {
|
|
188
195
|
return { ok: false, error: 'message has neither text nor attachments' }
|
|
@@ -221,8 +228,26 @@ export function createOutboundCallback(deps: {
|
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
if (text !== '') {
|
|
231
|
+
// KakaoTalk's native reply payload is built from the *source* message
|
|
232
|
+
// (author, original text, type), which the SDK does not derive from a
|
|
233
|
+
// bare log_id — we resolve it from recent history. If that lookup can't
|
|
234
|
+
// find the target (scrolled past the window, or the fetch failed), we
|
|
235
|
+
// degrade to the same blockquote anchor the router uses for quote-mode
|
|
236
|
+
// adapters, so the reply still visibly references the right message.
|
|
237
|
+
let outboundText = text
|
|
238
|
+
let replyTarget: KakaoReplyTarget | undefined
|
|
239
|
+
if (msg.replyTo !== undefined) {
|
|
240
|
+
replyTarget = await resolveKakaoReplyTarget(client, msg.chat, msg.replyTo.externalMessageId, logger)
|
|
241
|
+
if (replyTarget === undefined && msg.replyTo.source !== undefined) {
|
|
242
|
+
outboundText = prependQuoteAnchor(text, msg.replyTo.source)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
224
245
|
try {
|
|
225
|
-
const result = await client.sendMessage(
|
|
246
|
+
const result = await client.sendMessage(
|
|
247
|
+
msg.chat,
|
|
248
|
+
outboundText,
|
|
249
|
+
replyTarget !== undefined ? { replyTo: replyTarget } : undefined,
|
|
250
|
+
)
|
|
226
251
|
if (!result.success) {
|
|
227
252
|
logger.error(`[kakaotalk] sendMessage status_code=${result.status_code} ${tag}`)
|
|
228
253
|
return { ok: false, error: `kakaotalk send failed with status ${result.status_code}` }
|
|
@@ -239,6 +264,30 @@ export function createOutboundCallback(deps: {
|
|
|
239
264
|
}
|
|
240
265
|
}
|
|
241
266
|
|
|
267
|
+
// KakaoTalk replies need the full source message, not just its log_id. Resolve
|
|
268
|
+
// it from the chat's recent history (matching the upstream CLI's approach).
|
|
269
|
+
// Returns undefined when the target isn't in the fetched window or the fetch
|
|
270
|
+
// throws — the caller degrades to the blockquote fallback in that case.
|
|
271
|
+
async function resolveKakaoReplyTarget(
|
|
272
|
+
client: Pick<KakaoTalkClient, 'getMessages'>,
|
|
273
|
+
chatId: string,
|
|
274
|
+
externalMessageId: string,
|
|
275
|
+
logger: KakaotalkAdapterLogger,
|
|
276
|
+
): Promise<KakaoReplyTarget | undefined> {
|
|
277
|
+
try {
|
|
278
|
+
const messages = await client.getMessages(chatId, { count: KAKAO_REPLY_LOOKUP_COUNT })
|
|
279
|
+
const target = messages.find((m) => m.log_id === externalMessageId)
|
|
280
|
+
if (target === undefined) {
|
|
281
|
+
logger.warn(`[kakaotalk] reply target log_id=${externalMessageId} not in last ${KAKAO_REPLY_LOOKUP_COUNT}`)
|
|
282
|
+
return undefined
|
|
283
|
+
}
|
|
284
|
+
return { log_id: target.log_id, author_id: target.author_id, message: target.message, type: target.type }
|
|
285
|
+
} catch (err) {
|
|
286
|
+
logger.warn(`[kakaotalk] reply target lookup failed: ${describe(err)}`)
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
242
291
|
export function createKakaoHistoryCallback(deps: {
|
|
243
292
|
client: Pick<KakaoTalkClient, 'getMessages'>
|
|
244
293
|
logger: KakaotalkAdapterLogger
|
|
@@ -251,8 +251,12 @@ export function createOutboundCallback(deps: {
|
|
|
251
251
|
|
|
252
252
|
try {
|
|
253
253
|
const rendered = toTelegramMarkdownV2(text)
|
|
254
|
-
const sendOptions: { message_thread_id?: number; parse_mode: 'MarkdownV2' } = {
|
|
254
|
+
const sendOptions: { message_thread_id?: number; reply_to_message_id?: number; parse_mode: 'MarkdownV2' } = {
|
|
255
|
+
parse_mode: 'MarkdownV2',
|
|
256
|
+
}
|
|
255
257
|
if (threadId !== undefined) sendOptions.message_thread_id = threadId
|
|
258
|
+
const replyToId = parseTelegramMessageId(msg.replyTo?.externalMessageId)
|
|
259
|
+
if (replyToId !== undefined) sendOptions.reply_to_message_id = replyToId
|
|
256
260
|
const sent = await client.sendMessage(msg.chat, rendered, sendOptions)
|
|
257
261
|
logger.info(`[telegram-bot] sent message_id=${sent.message_id} ${tag}`)
|
|
258
262
|
return { ok: true }
|
|
@@ -270,6 +274,12 @@ function parseThreadId(thread: string | null | undefined): number | undefined {
|
|
|
270
274
|
return Number.isFinite(n) ? n : undefined
|
|
271
275
|
}
|
|
272
276
|
|
|
277
|
+
function parseTelegramMessageId(id: string | null | undefined): number | undefined {
|
|
278
|
+
if (id === null || id === undefined || id === '') return undefined
|
|
279
|
+
const n = Number(id)
|
|
280
|
+
return Number.isInteger(n) && n > 0 ? n : undefined
|
|
281
|
+
}
|
|
282
|
+
|
|
273
283
|
type TelegramFileResponse = {
|
|
274
284
|
ok: boolean
|
|
275
285
|
result?: { file_id: string; file_unique_id: string; file_size?: number; file_path?: string }
|
package/src/channels/router.ts
CHANGED
|
@@ -45,7 +45,9 @@ import type {
|
|
|
45
45
|
InboundMessage,
|
|
46
46
|
OutboundCallback,
|
|
47
47
|
OutboundMessage,
|
|
48
|
+
QuoteAnchorSource,
|
|
48
49
|
ResolvedChannelNames,
|
|
50
|
+
SendErrorCode,
|
|
49
51
|
SendResult,
|
|
50
52
|
TypingCallback,
|
|
51
53
|
} from './types'
|
|
@@ -98,6 +100,23 @@ export const SESSION_GC_INTERVAL_MS = 60 * 1000
|
|
|
98
100
|
// Enforced inside router.send for `source: 'tool'` callers; system
|
|
99
101
|
// recovery paths (`source: 'system'`) bypass.
|
|
100
102
|
export const MAX_CHANNEL_SENDS_PER_TURN = 10
|
|
103
|
+
// Ceiling on tool-source channel sends that a same-turn router policy DENIED
|
|
104
|
+
// without delivering — `skip-locked`, `turn-cap`, or `duplicate`. Such denials
|
|
105
|
+
// return a soft error and do NOT increment `consecutiveSends`, so a model that
|
|
106
|
+
// ignores the denial and retries never trips `MAX_CHANNEL_SENDS_PER_TURN`.
|
|
107
|
+
// Both production livelocks had this shape: the model alternated a no-op
|
|
108
|
+
// `skip_response` with a denied `channel_reply` (~200-400x in one
|
|
109
|
+
// `session.prompt()`) — the interleaving defeated the byte-identical
|
|
110
|
+
// loop-guard's 5-in-a-row streak, and the denials bypassed the send cap. One
|
|
111
|
+
// turn was all `skip-locked`, the other all `duplicate` (byte-identical text).
|
|
112
|
+
// Past this ceiling we ABORT the run's AbortSignal (`agent.abort()`), which
|
|
113
|
+
// ends the turn on the next assistant stream. We can't just throw: the pi tool
|
|
114
|
+
// executor catches a tool's throw into an error result and the turn continues.
|
|
115
|
+
// Counted per send-target and only when NO concurrent reservation for that
|
|
116
|
+
// target is in flight, so a legitimate parallel send-burst (one winner + many
|
|
117
|
+
// same-tick duplicate/cap denials) is never mistaken for a loop. Reset at turn
|
|
118
|
+
// start alongside `turnSeq`.
|
|
119
|
+
export const MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN = 3
|
|
101
120
|
// Rolling window for outbound send-rate telemetry. 5s matches Discord's
|
|
102
121
|
// rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
|
|
103
122
|
// 1 msg/s sustained. The window is observational; exceeding the burst
|
|
@@ -347,6 +366,19 @@ type LiveSession = {
|
|
|
347
366
|
// regardless of which order the model tried them in. Updated only at
|
|
348
367
|
// turn start; reads against the live counter elsewhere are intentional.
|
|
349
368
|
successfulSendsAtTurnStart: number
|
|
369
|
+
// Per-send-target count of tool-source sends with a reservation currently
|
|
370
|
+
// in flight (slot reserved, outbound callback not yet settled). Lets the
|
|
371
|
+
// policy-denial guard tell a legitimate parallel send-burst (denials that
|
|
372
|
+
// race a still-in-flight winner) from a sequential retry loop (denials with
|
|
373
|
+
// nothing in flight). Incremented at reservation, decremented in the
|
|
374
|
+
// callback-loop `finally` so an adapter throw can't strand a target.
|
|
375
|
+
inFlightToolSends: Map<string, number>
|
|
376
|
+
// Per-send-target count of policy-denied tool sends this turn that did NOT
|
|
377
|
+
// race an in-flight reservation. Drives the throw at
|
|
378
|
+
// `MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN` that breaks the alternating-tool
|
|
379
|
+
// livelock the byte-identical loop-guard misses. Reset at turn start and
|
|
380
|
+
// cleared per-target on a successful delivery to that target.
|
|
381
|
+
policyDeniedToolSendsThisTurn: Map<string, number>
|
|
350
382
|
// Stamped by `markTurnSkipped` (called from the `skip_response` tool)
|
|
351
383
|
// with the current `turnSeq`. Read at the top of `validateChannelTurn`:
|
|
352
384
|
// if it matches the just-completed turn, recovery is skipped entirely
|
|
@@ -1011,6 +1043,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1011
1043
|
successfulChannelSends: 0,
|
|
1012
1044
|
turnSeq: 0,
|
|
1013
1045
|
successfulSendsAtTurnStart: 0,
|
|
1046
|
+
inFlightToolSends: new Map(),
|
|
1047
|
+
policyDeniedToolSendsThisTurn: new Map(),
|
|
1014
1048
|
skippedTurn: null,
|
|
1015
1049
|
pendingQuoteCandidate: null,
|
|
1016
1050
|
recentEngagedPeerBotTurns: [],
|
|
@@ -1370,6 +1404,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1370
1404
|
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
1371
1405
|
live.turnSeq++
|
|
1372
1406
|
live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
|
|
1407
|
+
live.policyDeniedToolSendsThisTurn.clear()
|
|
1373
1408
|
await fireSessionTurnStart(live, text)
|
|
1374
1409
|
try {
|
|
1375
1410
|
await live.session.prompt(text)
|
|
@@ -1875,7 +1910,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1875
1910
|
if (live && source === 'tool' && live.pendingQuoteCandidate !== null) {
|
|
1876
1911
|
const quoteCandidate = refreshQuoteCandidate(live.pendingQuoteCandidate, live.contextBuffer)
|
|
1877
1912
|
const anchor = decideQuoteAnchor(quoteCandidate, now(), options.configForAdapter(msg.adapter))
|
|
1878
|
-
if (anchor !== null)
|
|
1913
|
+
if (anchor !== null) {
|
|
1914
|
+
msg =
|
|
1915
|
+
resolveReplyRenderMode(msg) === 'native'
|
|
1916
|
+
? { ...msg, replyTo: { externalMessageId: anchor.externalMessageId, source: anchor.source } }
|
|
1917
|
+
: { ...msg, text: prependQuoteAnchor(msg.text ?? '', anchor.source) }
|
|
1918
|
+
}
|
|
1879
1919
|
live.pendingQuoteCandidate = null
|
|
1880
1920
|
}
|
|
1881
1921
|
const text = normalizeSendText(msg.text)
|
|
@@ -1892,19 +1932,52 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1892
1932
|
let priorLastSentText: string | undefined
|
|
1893
1933
|
let reserved = false
|
|
1894
1934
|
if (live && source === 'tool') {
|
|
1935
|
+
// Every same-turn policy denial (skip-locked / turn-cap / duplicate)
|
|
1936
|
+
// returns a soft error and does NOT increment `consecutiveSends`, so a
|
|
1937
|
+
// model that ignores the denial and retries never trips the send cap. To
|
|
1938
|
+
// bound that loop we route all three through one tally that ABORTS the run
|
|
1939
|
+
// past the ceiling. The discriminator that keeps legitimate parallel
|
|
1940
|
+
// send-bursts soft: a denial only counts when NO reservation for the same
|
|
1941
|
+
// target is in flight. In a `Promise.all` burst the synchronous denials
|
|
1942
|
+
// all race the one in-flight winner, so they don't count; a sequential
|
|
1943
|
+
// retry loop has nothing in flight, so it does. See
|
|
1944
|
+
// `MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN`.
|
|
1945
|
+
//
|
|
1946
|
+
// Why abort, not throw: pi-agent-core's tool executor catches a throw
|
|
1947
|
+
// from a tool's execute() and converts it into an `isError` tool result —
|
|
1948
|
+
// the turn would continue and the model could retry. The only thing that
|
|
1949
|
+
// actually ends an in-flight turn is aborting the run's AbortSignal:
|
|
1950
|
+
// `agent.abort()` flips it synchronously, then the NEXT assistant stream
|
|
1951
|
+
// (after this tool returns) sees the aborted signal and ends the turn with
|
|
1952
|
+
// stopReason 'aborted'. We must NOT call `session.abort()` here — it
|
|
1953
|
+
// `await`s `waitForIdle()`, which would deadlock waiting for the very run
|
|
1954
|
+
// this tool call belongs to. `agent.abort()` is the signal-only,
|
|
1955
|
+
// non-blocking variant. We still return the soft denial for this call.
|
|
1956
|
+
const denyPolicyToolSend = (error: string, code: SendErrorCode): SendResult => {
|
|
1957
|
+
if ((live.inFlightToolSends.get(sendKey) ?? 0) > 0) {
|
|
1958
|
+
return { ok: false, error, code }
|
|
1959
|
+
}
|
|
1960
|
+
const count = (live.policyDeniedToolSendsThisTurn.get(sendKey) ?? 0) + 1
|
|
1961
|
+
live.policyDeniedToolSendsThisTurn.set(sendKey, count)
|
|
1962
|
+
if (count >= MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN) {
|
|
1963
|
+
logger.warn(`[channels] ${live.keyId}: aborting turn — ${count} policy-denied channel sends (last: ${code})`)
|
|
1964
|
+
if (live.session.agent.signal?.aborted !== true) live.session.agent.abort()
|
|
1965
|
+
}
|
|
1966
|
+
return { ok: false, error, code }
|
|
1967
|
+
}
|
|
1895
1968
|
// Tool-source send after `skip_response` for the same turn is a contract
|
|
1896
1969
|
// violation: the model already committed to silence. Reject before any
|
|
1897
1970
|
// state mutation so the model gets a clear error and the channel stays
|
|
1898
1971
|
// silent. System-source sends (recovery, role-claim) are not affected.
|
|
1899
1972
|
if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
|
|
1900
|
-
return
|
|
1973
|
+
return denyPolicyToolSend(SKIP_RESPONSE_LOCK_ERROR, 'skip-locked')
|
|
1901
1974
|
}
|
|
1902
1975
|
const currentCount = live.consecutiveSends.get(sendKey) ?? 0
|
|
1903
1976
|
if (currentCount >= MAX_CHANNEL_SENDS_PER_TURN) {
|
|
1904
|
-
return
|
|
1977
|
+
return denyPolicyToolSend(TURN_CAP_ERROR, 'turn-cap')
|
|
1905
1978
|
}
|
|
1906
1979
|
if (text !== undefined && live.lastSentText.get(sendKey) === text) {
|
|
1907
|
-
return
|
|
1980
|
+
return denyPolicyToolSend(DUPLICATE_SEND_ERROR, 'duplicate')
|
|
1908
1981
|
}
|
|
1909
1982
|
// Reserve the slot before awaiting. If the callback rejects we roll
|
|
1910
1983
|
// back below; if it succeeds we keep the increment. The slot reserve
|
|
@@ -1915,6 +1988,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1915
1988
|
priorLastSentText = live.lastSentText.get(sendKey)
|
|
1916
1989
|
live.consecutiveSends.set(sendKey, currentCount + 1)
|
|
1917
1990
|
if (text !== undefined) live.lastSentText.set(sendKey, text)
|
|
1991
|
+
live.inFlightToolSends.set(sendKey, (live.inFlightToolSends.get(sendKey) ?? 0) + 1)
|
|
1918
1992
|
reserved = true
|
|
1919
1993
|
}
|
|
1920
1994
|
|
|
@@ -1924,13 +1998,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1924
1998
|
const snapshot = Array.from(callbacks)
|
|
1925
1999
|
let lastError: string | undefined
|
|
1926
2000
|
let delivered = false
|
|
1927
|
-
|
|
1928
|
-
const
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
2001
|
+
try {
|
|
2002
|
+
for (const cb of snapshot) {
|
|
2003
|
+
const result = await cb(msg)
|
|
2004
|
+
if (result.ok) {
|
|
2005
|
+
delivered = true
|
|
2006
|
+
break
|
|
2007
|
+
}
|
|
2008
|
+
lastError = result.error
|
|
2009
|
+
}
|
|
2010
|
+
} finally {
|
|
2011
|
+
// Clear the in-flight reservation even if a callback threw, so a flaky
|
|
2012
|
+
// adapter can never strand a target as permanently "in flight" and
|
|
2013
|
+
// disable the policy-denial guard for it.
|
|
2014
|
+
if (live && reserved) {
|
|
2015
|
+
const inFlight = (live.inFlightToolSends.get(sendKey) ?? 1) - 1
|
|
2016
|
+
if (inFlight <= 0) live.inFlightToolSends.delete(sendKey)
|
|
2017
|
+
else live.inFlightToolSends.set(sendKey, inFlight)
|
|
1932
2018
|
}
|
|
1933
|
-
lastError = result.error
|
|
1934
2019
|
}
|
|
1935
2020
|
|
|
1936
2021
|
if (!delivered) {
|
|
@@ -1950,6 +2035,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1950
2035
|
|
|
1951
2036
|
if (live) {
|
|
1952
2037
|
live.successfulChannelSends++
|
|
2038
|
+
live.policyDeniedToolSendsThisTurn.delete(sendKey)
|
|
1953
2039
|
// Don't stop the heartbeat here: the agent may still be mid-turn and
|
|
1954
2040
|
// about to send another reply. drain()'s finally block owns turn-end
|
|
1955
2041
|
// stop. But Slack's adapter outbound callback explicitly clears
|
|
@@ -2480,12 +2566,7 @@ function formatAuthorLine(
|
|
|
2480
2566
|
return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
|
|
2481
2567
|
}
|
|
2482
2568
|
|
|
2483
|
-
export type QuoteAnchorSource
|
|
2484
|
-
adapter: AdapterId
|
|
2485
|
-
authorId: string
|
|
2486
|
-
authorName: string
|
|
2487
|
-
text: string
|
|
2488
|
-
}
|
|
2569
|
+
export type { QuoteAnchorSource } from './types'
|
|
2489
2570
|
|
|
2490
2571
|
// Picks the right author syntax for the platform so prompts and rendered
|
|
2491
2572
|
// quote anchors use the same form the user would type in that channel.
|
|
@@ -2557,6 +2638,7 @@ type QuoteAnchorBatchEntry = {
|
|
|
2557
2638
|
authorName: string
|
|
2558
2639
|
authorIsBot: boolean
|
|
2559
2640
|
receivedAt: number
|
|
2641
|
+
externalMessageId: string
|
|
2560
2642
|
}
|
|
2561
2643
|
|
|
2562
2644
|
type QuoteAnchorObservedEntry = {
|
|
@@ -2566,10 +2648,18 @@ type QuoteAnchorObservedEntry = {
|
|
|
2566
2648
|
|
|
2567
2649
|
export type QuoteAnchorCandidate = {
|
|
2568
2650
|
source: QuoteAnchorSource
|
|
2651
|
+
// Native id of the primary inbound, so a native-reply adapter can point at
|
|
2652
|
+
// the exact message; the blockquote fallback ignores it.
|
|
2653
|
+
externalMessageId: string
|
|
2569
2654
|
primaryReceivedAt: number
|
|
2570
2655
|
hadInterveningObserved: boolean
|
|
2571
2656
|
}
|
|
2572
2657
|
|
|
2658
|
+
export type QuoteAnchorTarget = {
|
|
2659
|
+
source: QuoteAnchorSource
|
|
2660
|
+
externalMessageId: string
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2573
2663
|
// Strips both current `[<Adapter> attachment #N: ...]` and legacy
|
|
2574
2664
|
// `[<Adapter> message with ...]` placeholders that adapter
|
|
2575
2665
|
// classifiers synthesize for non-text inbounds (KakaoTalk stickers,
|
|
@@ -2620,6 +2710,7 @@ export function captureQuoteCandidate(
|
|
|
2620
2710
|
if (cleaned === '') return null
|
|
2621
2711
|
return {
|
|
2622
2712
|
source: { adapter, authorId: primary.authorId, authorName: primary.authorName, text: cleaned },
|
|
2713
|
+
externalMessageId: primary.externalMessageId,
|
|
2623
2714
|
primaryReceivedAt: primary.receivedAt,
|
|
2624
2715
|
hadInterveningObserved: hasInterveningObserved(primary.receivedAt, observed),
|
|
2625
2716
|
}
|
|
@@ -2647,12 +2738,34 @@ export function decideQuoteAnchor(
|
|
|
2647
2738
|
candidate: QuoteAnchorCandidate | null,
|
|
2648
2739
|
_nowMs: number,
|
|
2649
2740
|
adapterConfig: ChannelAdapterConfig | undefined,
|
|
2650
|
-
):
|
|
2741
|
+
): QuoteAnchorTarget | null {
|
|
2651
2742
|
if (candidate === null) return null
|
|
2652
2743
|
const config = adapterConfig?.quotedReply
|
|
2653
2744
|
if (config !== undefined && config.enabled === false) return null
|
|
2654
2745
|
if (!candidate.hadInterveningObserved) return null
|
|
2655
|
-
return candidate.source
|
|
2746
|
+
return { source: candidate.source, externalMessageId: candidate.externalMessageId }
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
export type ReplyRenderMode = 'native' | 'quote'
|
|
2750
|
+
|
|
2751
|
+
// Per-adapter, per-shape decision: can this exact outbound carry a native
|
|
2752
|
+
// platform reply, or must it degrade to the blockquote fallback? Conditional
|
|
2753
|
+
// because native support is not uniform within an adapter — Telegram's
|
|
2754
|
+
// `sendMessage` accepts `reply_to_message_id` but `sendDocument` does not, so
|
|
2755
|
+
// an attachment-only Telegram reply must quote; the same text-only restriction
|
|
2756
|
+
// holds for Discord (`message_reference` rides on the text send, file uploads
|
|
2757
|
+
// land bare) and KakaoTalk. Slack's primitive is `thread`, not a per-message
|
|
2758
|
+
// reply, so it stays quote; GitHub's PR-review reply already rides on `thread`.
|
|
2759
|
+
//
|
|
2760
|
+
// KakaoTalk is `native` here even though its reply payload can fail to resolve
|
|
2761
|
+
// at send time — the adapter degrades to the blockquote fallback itself using
|
|
2762
|
+
// `replyTo.source`, so the router still routes it down the native branch.
|
|
2763
|
+
const NATIVE_REPLY_TEXT_ADAPTERS = new Set<AdapterId>(['telegram-bot', 'discord-bot', 'kakaotalk'])
|
|
2764
|
+
|
|
2765
|
+
export function resolveReplyRenderMode(msg: OutboundMessage): ReplyRenderMode {
|
|
2766
|
+
const hasText = normalizeSendText(msg.text) !== undefined
|
|
2767
|
+
if (hasText && NATIVE_REPLY_TEXT_ADAPTERS.has(msg.adapter)) return 'native'
|
|
2768
|
+
return 'quote'
|
|
2656
2769
|
}
|
|
2657
2770
|
|
|
2658
2771
|
type Sliced = { kind: 'message'; message: ChannelHistoryMessage } | { kind: 'elision'; elidedCount: number }
|
package/src/channels/types.ts
CHANGED
|
@@ -126,6 +126,28 @@ export type OutboundMessage = {
|
|
|
126
126
|
// `uploadFile` does not accept a content body or a thread id, see the
|
|
127
127
|
// adapter for the workaround details.
|
|
128
128
|
attachments?: OutboundAttachment[]
|
|
129
|
+
// Set by the router (native render mode + anchor fired) so an adapter can
|
|
130
|
+
// reply to the inbound it answers. Telegram/Discord consume `externalMessageId`;
|
|
131
|
+
// `quote`-mode adapters never see this (the router prepends the blockquote into
|
|
132
|
+
// `text` instead). `source` lets an adapter whose native primitive can fail at
|
|
133
|
+
// send time (KakaoTalk: payload built from a source message that may have
|
|
134
|
+
// scrolled out of history) degrade to the same blockquote fallback.
|
|
135
|
+
replyTo?: OutboundReplyTo
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type OutboundReplyTo = {
|
|
139
|
+
externalMessageId: string
|
|
140
|
+
source?: QuoteAnchorSource
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// `adapter` selects the per-platform author-mention syntax in the blockquote
|
|
144
|
+
// fallback. Lives here (not router.ts) so adapters can reconstruct a native
|
|
145
|
+
// reply payload from the same shape the router renders quotes from.
|
|
146
|
+
export type QuoteAnchorSource = {
|
|
147
|
+
adapter: AdapterId
|
|
148
|
+
authorId: string
|
|
149
|
+
authorName: string
|
|
150
|
+
text: string
|
|
129
151
|
}
|
|
130
152
|
|
|
131
153
|
export type SendErrorCode =
|
package/src/config/providers.ts
CHANGED
|
@@ -197,10 +197,11 @@ export const KNOWN_PROVIDERS = {
|
|
|
197
197
|
// anthropic`) before relying on the env-var path. Same rule applies to any
|
|
198
198
|
// future dual-auth provider — keep the surprise in mind when expanding.
|
|
199
199
|
//
|
|
200
|
-
// Model lineup is the current GA tier as of 2026-
|
|
201
|
-
// released
|
|
202
|
-
// Oct 1 2025). Anthropic's own model overview
|
|
203
|
-
//
|
|
200
|
+
// Model lineup is the current GA tier as of 2026-05-29: Opus 4.8 (top,
|
|
201
|
+
// released May 2026), Opus 4.7 (prior top, Apr 16 2026), Sonnet 4.6 (mid,
|
|
202
|
+
// Feb 5 2026), Haiku 4.5 (fast, Oct 1 2025). Anthropic's own model overview
|
|
203
|
+
// lists the latest Opus/Sonnet/Haiku as the current recommended set and
|
|
204
|
+
// flags earlier Opus/Sonnet variants with
|
|
204
205
|
// "Consider migrating to current models." Opus 4 / Sonnet 4 are deprecated
|
|
205
206
|
// (retirement: Jun 15 2026); the 4.5/4.6 alternates remain Active but are
|
|
206
207
|
// not the recommended path.
|
|
@@ -276,6 +277,18 @@ export const KNOWN_PROVIDERS = {
|
|
|
276
277
|
contextWindow: 1000000,
|
|
277
278
|
maxTokens: 128000,
|
|
278
279
|
},
|
|
280
|
+
'claude-opus-4-8': {
|
|
281
|
+
id: 'claude-opus-4-8',
|
|
282
|
+
name: 'Claude Opus 4.8',
|
|
283
|
+
api: 'anthropic-messages',
|
|
284
|
+
provider: 'anthropic',
|
|
285
|
+
baseUrl: 'https://api.anthropic.com',
|
|
286
|
+
reasoning: true,
|
|
287
|
+
input: ['text', 'image'],
|
|
288
|
+
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
289
|
+
contextWindow: 1000000,
|
|
290
|
+
maxTokens: 128000,
|
|
291
|
+
},
|
|
279
292
|
},
|
|
280
293
|
},
|
|
281
294
|
fireworks: {
|
package/src/container/start.ts
CHANGED
|
@@ -464,12 +464,29 @@ export async function planStart({
|
|
|
464
464
|
// misattribute to bot detection. 2g matches the Playwright/Puppeteer
|
|
465
465
|
// canonical recommendation and is a memory cap, not an allocation (only
|
|
466
466
|
// used pages count against the host).
|
|
467
|
+
// `seccomp=unconfined` lets `bwrap(1)` (installed in baseline; see
|
|
468
|
+
// BASELINE_APT_PACKAGES in src/init/dockerfile.ts) create user/pid/mount
|
|
469
|
+
// namespaces from inside the container. Docker's default seccomp profile
|
|
470
|
+
// rejects `unshare(CLONE_NEWUSER)` and `clone(CLONE_NEWUSER)` for
|
|
471
|
+
// non-privileged containers, which is the right default for multi-tenant
|
|
472
|
+
// hosts (Kubernetes nodes, CI runners) but wrong for typeclaw: the outer
|
|
473
|
+
// container is a single-tenant trust boundary — the user trusts everything
|
|
474
|
+
// inside it equally, the .env and agent folder are already mounted in —
|
|
475
|
+
// so the multi-tenant protections seccomp adds are not load-bearing for
|
|
476
|
+
// typeclaw's threat model. The per-tool sandbox bwrap builds for subagents
|
|
477
|
+
// IS the real boundary against prompt-injected commands; that boundary is
|
|
478
|
+
// what `--security-opt seccomp=unconfined` exists to enable. See
|
|
479
|
+
// `docs/internals/sandbox.mdx` for the full rationale including why
|
|
480
|
+
// `--cap-add=SYS_ADMIN` was rejected as an alternative (narrower in
|
|
481
|
+
// syscalls but strictly worse in capability semantics).
|
|
467
482
|
const runArgs = [
|
|
468
483
|
'run',
|
|
469
484
|
'-d',
|
|
470
485
|
'--name',
|
|
471
486
|
containerName,
|
|
472
487
|
'--shm-size=2g',
|
|
488
|
+
'--security-opt',
|
|
489
|
+
'seccomp=unconfined',
|
|
473
490
|
'-p',
|
|
474
491
|
`${publishHost}:${hostPort}:${CONTAINER_PORT}`,
|
|
475
492
|
]
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -38,7 +38,27 @@ export type BuildDockerfileOptions = {
|
|
|
38
38
|
// self-heals: it spawns Xvfb (and exports DISPLAY) if the binary is on
|
|
39
39
|
// PATH, and execs the agent directly otherwise. See APT_FEATURES.xvfb
|
|
40
40
|
// below and `buildEntrypointShim`.
|
|
41
|
-
|
|
41
|
+
// `bubblewrap` ships the `bwrap(1)` setuid-less namespace sandboxer. It is
|
|
42
|
+
// included in baseline (not behind a toggle) because per-tool sandboxing of
|
|
43
|
+
// agent bash calls is a runtime concern resolved by the agent, not by the
|
|
44
|
+
// agent author. See `src/sandbox/` for the bwrap command builder, and
|
|
45
|
+
// `docs/internals/sandbox.mdx` for why bwrap is the right
|
|
46
|
+
// shape for per-call isolation inside an already-containerized agent. The
|
|
47
|
+
// outer container's `--security-opt seccomp=unconfined` (added in the same
|
|
48
|
+
// commit as this line; see `src/container/start.ts:planStart`) is what lets
|
|
49
|
+
// bwrap create user/pid/mount namespaces from inside Docker. Without that
|
|
50
|
+
// flag the seccomp default profile blocks `unshare(CLONE_NEWUSER)` and bwrap
|
|
51
|
+
// fails at startup. The two changes are load-bearing together — do not drop
|
|
52
|
+
// one without the other.
|
|
53
|
+
const BASELINE_APT_PACKAGES = [
|
|
54
|
+
'git',
|
|
55
|
+
'ca-certificates',
|
|
56
|
+
'curl',
|
|
57
|
+
'gnupg',
|
|
58
|
+
'iptables',
|
|
59
|
+
'util-linux',
|
|
60
|
+
'bubblewrap',
|
|
61
|
+
] as const
|
|
42
62
|
|
|
43
63
|
// curl-impersonate is the only currently-working way to query DuckDuckGo from
|
|
44
64
|
// a non-browser client on residential IPs in 2026. DDG fingerprints incoming
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { SandboxUnavailableError } from './errors'
|
|
2
|
+
|
|
3
|
+
// Cached because the binary cannot appear or disappear during a single
|
|
4
|
+
// process lifetime, and a probe per bash call is wasted work. Keyed by the
|
|
5
|
+
// resolved bwrap path so a test (or a consumer pinning a non-default path)
|
|
6
|
+
// re-probes instead of reading another path's cached result.
|
|
7
|
+
const availabilityCache = new Map<string, boolean>()
|
|
8
|
+
|
|
9
|
+
export async function ensureBwrapAvailable(options?: { bwrapPath?: string }): Promise<void> {
|
|
10
|
+
const bwrap = options?.bwrapPath ?? 'bwrap'
|
|
11
|
+
const cached = availabilityCache.get(bwrap)
|
|
12
|
+
if (cached === true) return
|
|
13
|
+
if (cached === false) throw new SandboxUnavailableError()
|
|
14
|
+
|
|
15
|
+
const available = await probe(bwrap)
|
|
16
|
+
availabilityCache.set(bwrap, available)
|
|
17
|
+
if (!available) throw new SandboxUnavailableError()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function probe(bwrap: string): Promise<boolean> {
|
|
21
|
+
// Bun.spawn throws synchronously with ENOENT when the binary is not on
|
|
22
|
+
// PATH, rather than resolving with a non-zero exit code — so the
|
|
23
|
+
// "not installed" case lands in the catch, not in proc.exitCode.
|
|
24
|
+
try {
|
|
25
|
+
const proc = Bun.spawn([bwrap, '--version'], { stdout: 'ignore', stderr: 'ignore' })
|
|
26
|
+
await proc.exited
|
|
27
|
+
return proc.exitCode === 0
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function _resetBwrapAvailabilityCacheForTests(): void {
|
|
34
|
+
availabilityCache.clear()
|
|
35
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { SandboxPolicyError } from './errors'
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_SANDBOX_ENV,
|
|
4
|
+
type SandboxCommandFilter,
|
|
5
|
+
type SandboxEnvPolicy,
|
|
6
|
+
type SandboxMount,
|
|
7
|
+
type SandboxPolicy,
|
|
8
|
+
} from './policy'
|
|
9
|
+
import { formatCommand } from './quote'
|
|
10
|
+
|
|
11
|
+
export type SandboxedCommand = {
|
|
12
|
+
argv: string[]
|
|
13
|
+
commandString: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Pure: no I/O, no bwrap availability probe (that is `ensureBwrapAvailable`'s
|
|
17
|
+
// job). Given a bash command and a policy, returns the bwrap-wrapped argv plus
|
|
18
|
+
// a shell-quoted rendering of it. Knows nothing about subagents, origins, or
|
|
19
|
+
// the agent runtime — a consumer resolves a policy from whatever context it
|
|
20
|
+
// has and calls this. Throws SandboxPolicyError only when the consumer opted
|
|
21
|
+
// into the command-filter knobs and the command violates them.
|
|
22
|
+
export function buildSandboxedCommand(command: string, policy: SandboxPolicy = {}): SandboxedCommand {
|
|
23
|
+
if (policy.commandFilter !== undefined) {
|
|
24
|
+
applyCommandFilter(command, policy.commandFilter)
|
|
25
|
+
}
|
|
26
|
+
const argv = buildArgv(command, policy)
|
|
27
|
+
return { argv, commandString: formatCommand(argv) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildArgv(command: string, policy: SandboxPolicy): string[] {
|
|
31
|
+
const bwrap = policy.bwrapPath ?? 'bwrap'
|
|
32
|
+
const argv: string[] = [bwrap, '--unshare-all']
|
|
33
|
+
|
|
34
|
+
if (policy.network === 'inherit') {
|
|
35
|
+
// --unshare-all already unshared the net namespace; --share-net rejoins
|
|
36
|
+
// the outer container's network. Other namespaces (user/pid/mount/ipc/
|
|
37
|
+
// uts/cgroup) stay unshared. Default ('none' / undefined) leaves the net
|
|
38
|
+
// namespace isolated — prompt-injected bash cannot exfiltrate over the
|
|
39
|
+
// network without the consumer explicitly opting in.
|
|
40
|
+
argv.push('--share-net')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const proc = policy.process ?? {}
|
|
44
|
+
if (proc.newSession !== false) {
|
|
45
|
+
// Drops the controlling terminal so the contained process cannot push
|
|
46
|
+
// input back into the agent's tty via TIOCSTI. Mandated by
|
|
47
|
+
// docs/internals/sandbox.mdx. Harmless for a one-shot `bash -c`.
|
|
48
|
+
argv.push('--new-session')
|
|
49
|
+
}
|
|
50
|
+
if (proc.dieWithParent !== false) {
|
|
51
|
+
argv.push('--die-with-parent')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
argv.push('--clearenv')
|
|
55
|
+
for (const [key, value] of Object.entries(resolveEnv(policy.env))) {
|
|
56
|
+
argv.push('--setenv', key, value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
argv.push('--ro-bind', '/usr', '/usr', '--ro-bind', '/etc', '/etc', '--dev', '/dev', '--tmpfs', '/tmp')
|
|
60
|
+
|
|
61
|
+
if ((policy.proc ?? 'tmpfs') === 'tmpfs') {
|
|
62
|
+
// --tmpfs /proc, never --proc /proc (OrbStack's kernel blocks
|
|
63
|
+
// mount("proc",...) from user namespaces) and never --dev-bind /proc /proc
|
|
64
|
+
// (leaks the outer container's /proc/N/environ — including
|
|
65
|
+
// FIREWORKS_API_KEY — into the sandbox). See sandbox.mdx.
|
|
66
|
+
argv.push('--tmpfs', '/proc')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const mount of policy.mounts ?? []) {
|
|
70
|
+
appendMount(argv, mount)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (policy.cwd !== undefined) {
|
|
74
|
+
argv.push('--chdir', policy.cwd)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
argv.push('bash', '-c', command)
|
|
78
|
+
return argv
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function appendMount(argv: string[], mount: SandboxMount): void {
|
|
82
|
+
switch (mount.type) {
|
|
83
|
+
case 'ro-bind':
|
|
84
|
+
argv.push('--ro-bind', mount.source, mount.dest)
|
|
85
|
+
return
|
|
86
|
+
case 'bind':
|
|
87
|
+
argv.push('--bind', mount.source, mount.dest)
|
|
88
|
+
return
|
|
89
|
+
case 'tmpfs':
|
|
90
|
+
argv.push('--tmpfs', mount.dest)
|
|
91
|
+
return
|
|
92
|
+
case 'dev':
|
|
93
|
+
argv.push('--dev', mount.dest)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveEnv(env: SandboxEnvPolicy | undefined): Record<string, string> {
|
|
99
|
+
const resolved: Record<string, string> = { ...DEFAULT_SANDBOX_ENV, ...env?.set }
|
|
100
|
+
for (const key of env?.passthrough ?? []) {
|
|
101
|
+
const value = process.env[key]
|
|
102
|
+
if (value !== undefined) resolved[key] = value
|
|
103
|
+
}
|
|
104
|
+
return resolved
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Token-boundary match: the normalized command must equal a prefix exactly or
|
|
108
|
+
// start with `prefix + ' '`. Substring matching would let `git-evil ...` slip
|
|
109
|
+
// past a `git` prefix; this does not.
|
|
110
|
+
const ALLOWLIST_WHITESPACE = /\s+/g
|
|
111
|
+
const FORBIDDEN_METACHARS = /[;&|`$()<>\\\n]/
|
|
112
|
+
|
|
113
|
+
function applyCommandFilter(command: string, filter: SandboxCommandFilter): void {
|
|
114
|
+
if (filter.rejectShellMetacharacters === true && FORBIDDEN_METACHARS.test(command)) {
|
|
115
|
+
throw new SandboxPolicyError(
|
|
116
|
+
'command contains a forbidden shell metacharacter. This policy only permits simple commands without ; & | ` $ ( ) < > \\ or newlines.',
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
if (filter.allowPrefixes !== undefined) {
|
|
120
|
+
const normalized = command.trim().replace(ALLOWLIST_WHITESPACE, ' ')
|
|
121
|
+
const matched = filter.allowPrefixes.some((p) => normalized === p || normalized.startsWith(`${p} `))
|
|
122
|
+
if (!matched) {
|
|
123
|
+
throw new SandboxPolicyError(
|
|
124
|
+
`command does not match any allowed prefix. Allowed: ${filter.allowPrefixes.join(', ')}`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class SandboxUnavailableError extends Error {
|
|
2
|
+
override readonly name = 'SandboxUnavailableError'
|
|
3
|
+
constructor() {
|
|
4
|
+
super(
|
|
5
|
+
'sandbox unavailable: bwrap binary not found on PATH. Refusing to run a command that requires sandboxing without the kernel boundary in place.',
|
|
6
|
+
)
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Raised by the optional command-filter knobs (allowPrefixes,
|
|
11
|
+
// rejectShellMetacharacters). These are consumer-opt-in restrictions layered
|
|
12
|
+
// ABOVE the always-on kernel containment, so a rejection here is a policy
|
|
13
|
+
// decision the consumer asked for — not a failure of the sandbox itself. The
|
|
14
|
+
// message is phrased for the model to read and self-correct from.
|
|
15
|
+
export class SandboxPolicyError extends Error {
|
|
16
|
+
override readonly name = 'SandboxPolicyError'
|
|
17
|
+
constructor(reason: string) {
|
|
18
|
+
super(`sandbox policy rejected command: ${reason}`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { buildSandboxedCommand, type SandboxedCommand } from './build'
|
|
2
|
+
export { ensureBwrapAvailable } from './availability'
|
|
3
|
+
export { formatCommand, shellQuote } from './quote'
|
|
4
|
+
export { SandboxPolicyError, SandboxUnavailableError } from './errors'
|
|
5
|
+
export {
|
|
6
|
+
DEFAULT_SANDBOX_ENV,
|
|
7
|
+
type SandboxCommandFilter,
|
|
8
|
+
type SandboxEnvPolicy,
|
|
9
|
+
type SandboxMount,
|
|
10
|
+
type SandboxNetwork,
|
|
11
|
+
type SandboxPolicy,
|
|
12
|
+
type SandboxProcessPolicy,
|
|
13
|
+
type SandboxProcStrategy,
|
|
14
|
+
} from './policy'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type SandboxMount =
|
|
2
|
+
| { type: 'ro-bind'; source: string; dest: string }
|
|
3
|
+
| { type: 'bind'; source: string; dest: string }
|
|
4
|
+
| { type: 'tmpfs'; dest: string }
|
|
5
|
+
| { type: 'dev'; dest: string }
|
|
6
|
+
|
|
7
|
+
export type SandboxNetwork = 'none' | 'inherit'
|
|
8
|
+
|
|
9
|
+
export type SandboxProcStrategy = 'tmpfs' | 'none'
|
|
10
|
+
|
|
11
|
+
export type SandboxEnvPolicy = {
|
|
12
|
+
set?: Record<string, string>
|
|
13
|
+
passthrough?: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SandboxCommandFilter = {
|
|
17
|
+
allowPrefixes?: string[]
|
|
18
|
+
rejectShellMetacharacters?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SandboxProcessPolicy = {
|
|
22
|
+
newSession?: boolean
|
|
23
|
+
dieWithParent?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SandboxPolicy = {
|
|
27
|
+
bwrapPath?: string
|
|
28
|
+
cwd?: string
|
|
29
|
+
mounts?: SandboxMount[]
|
|
30
|
+
network?: SandboxNetwork
|
|
31
|
+
env?: SandboxEnvPolicy
|
|
32
|
+
commandFilter?: SandboxCommandFilter
|
|
33
|
+
process?: SandboxProcessPolicy
|
|
34
|
+
proc?: SandboxProcStrategy
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The env the sandbox always re-introduces after `--clearenv`. Anything not
|
|
38
|
+
// listed here (or explicitly named in `env.set` / `env.passthrough` by the
|
|
39
|
+
// consumer) is invisible inside the sandbox. This is the load-bearing leak
|
|
40
|
+
// guard: the container env holds FIREWORKS_API_KEY and GH_TOKEN, and env
|
|
41
|
+
// inheritance is the single highest-risk exfil path for prompt-injected bash.
|
|
42
|
+
// HOME points at /tmp because the sandbox mounts /tmp as a fresh tmpfs.
|
|
43
|
+
export const DEFAULT_SANDBOX_ENV: Record<string, string> = {
|
|
44
|
+
PATH: '/usr/local/bin:/usr/bin:/bin',
|
|
45
|
+
HOME: '/tmp',
|
|
46
|
+
LANG: 'C.UTF-8',
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// POSIX shell quoting for rendering a bwrap argv array into a single
|
|
2
|
+
// `bash -c`-safe string. Today's bash tool accepts a string `command` slot
|
|
3
|
+
// (`mutableArgs.command`), so the sandbox primitive renders its canonical
|
|
4
|
+
// argv into a quoted string the agent runtime can drop in unchanged.
|
|
5
|
+
//
|
|
6
|
+
// This is a local copy of the same helper in `src/update/index.ts`. It is
|
|
7
|
+
// deliberately not promoted to a shared module yet: two call sites do not
|
|
8
|
+
// justify the coupling, and this primitive is meant to stand alone with zero
|
|
9
|
+
// imports from the rest of the tree. Promote to `src/shared/shell.ts` only
|
|
10
|
+
// when a third independent consumer appears.
|
|
11
|
+
export function shellQuote(arg: string): string {
|
|
12
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg)) return arg
|
|
13
|
+
return `'${arg.replaceAll("'", "'\\''")}'`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatCommand(argv: readonly string[]): string {
|
|
17
|
+
return argv.map(shellQuote).join(' ')
|
|
18
|
+
}
|
|
@@ -11,14 +11,16 @@ This means **you are messaging as a person, not as a bot.** Other participants s
|
|
|
11
11
|
|
|
12
12
|
## What KakaoTalk does NOT support
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
14
|
+
KakaoTalk renders messages as plain text — it has no rich-text formatting. **Write plain text from the start.** The adapter strips common markdown as a safety net before sending (so an accidental `**bold**` won't leak literal asterisks), but treat that as a last-resort guard, not a license to write markdown: the strip removes _markers_, it cannot make formatting-dependent layouts like tables readable. Compose for a plain-text surface and you control the result; lean on the stripper and you get whatever falls out.
|
|
15
|
+
|
|
16
|
+
Specifically, do not rely on any of the following — write the plain-text equivalent yourself:
|
|
17
|
+
|
|
18
|
+
- **Bold / italic / strikethrough** — emphasize with word choice or capitalization (sparingly), not `**asterisks**`.
|
|
19
|
+
- **Headings** — `# H1`, `## H2`, `### H3` carry no visual weight here. Use a short label line or just lead with the point.
|
|
20
|
+
- **Tables** — the stripper cannot rescue a pipe-delimited table; it would collapse into an unreadable line. Use bullet lists or short prose paragraphs instead.
|
|
21
|
+
- **Code fences** — for short snippets, paste the code inline as plain text. For long snippets, summarize and offer to send it via another channel.
|
|
22
|
+
- **Inline code** — just write `foo`, no backticks.
|
|
23
|
+
- **Links with display text** — send the bare URL on its own line; the KakaoTalk client auto-links it. (A `[label](url)` that slips through is reduced to `label (url)`, but a bare URL reads cleaner.)
|
|
22
24
|
- **Mentions** — there is no `@user` syntax that the protocol surfaces. Address people by name in the message body.
|
|
23
25
|
- **Threads / replies-with-quote** — every message is a top-level chat post. There is no per-message reply UI.
|
|
24
26
|
- **Outbound stickers / emoticons** — the KakaoTalk sticker store requires desktop-app purchase flows that the SDK does not replicate. Inbound stickers ARE surfaced (see below), but you cannot send one. If the user asks for a sticker, acknowledge the limit and offer text.
|
|
@@ -106,4 +108,4 @@ The adapter drops every inbound where `event.author_id` equals the logged-in acc
|
|
|
106
108
|
|
|
107
109
|
## When you cannot answer in KakaoTalk
|
|
108
110
|
|
|
109
|
-
If the user asks you to do something the adapter cannot do (
|
|
111
|
+
If the user asks you to do something the adapter cannot do (post in a thread, send a sticker, render a real table), say so plainly. Files are fine — those go through `attachments[]` as described above — but threading, stickers, and rich formatting are real limits. Markdown markers you emit get stripped to plain text automatically, so a stray `**` won't leak; the limit is that nothing renders as formatting, not that it crashes. Acknowledge the limit instead of silently dropping the request.
|
package/typeclaw.schema.json
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"anthropic/claude-haiku-4-5",
|
|
33
33
|
"anthropic/claude-sonnet-4-6",
|
|
34
34
|
"anthropic/claude-opus-4-7",
|
|
35
|
+
"anthropic/claude-opus-4-8",
|
|
35
36
|
"fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
|
|
36
37
|
"zai/glm-4.5-air",
|
|
37
38
|
"zai/glm-4.6",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"anthropic/claude-haiku-4-5",
|
|
60
61
|
"anthropic/claude-sonnet-4-6",
|
|
61
62
|
"anthropic/claude-opus-4-7",
|
|
63
|
+
"anthropic/claude-opus-4-8",
|
|
62
64
|
"fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
|
|
63
65
|
"zai/glm-4.5-air",
|
|
64
66
|
"zai/glm-4.6",
|