typeclaw 0.11.1 → 0.13.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/README.md +1 -1
- package/package.json +1 -1
- package/scripts/dump-system-prompt.ts +12 -11
- package/src/agent/index.ts +15 -22
- package/src/agent/loop-guard.ts +170 -0
- package/src/agent/model-fallback.ts +2 -1
- package/src/agent/multimodal/index.ts +1 -1
- package/src/agent/multimodal/look-at.ts +118 -55
- package/src/agent/plugin-tools.ts +57 -0
- package/src/agent/subagents.ts +2 -1
- package/src/agent/system-prompt.ts +28 -25
- package/src/agent/tools/channel-fetch-attachment.ts +45 -16
- package/src/agent/tools/normalize-ref.ts +11 -0
- package/src/bundled-plugins/reviewer/index.ts +11 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
- package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
- package/src/channels/adapters/discord-bot-classify.ts +32 -24
- package/src/channels/adapters/github/inbound.ts +19 -2
- package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
- package/src/channels/adapters/kakaotalk-classify.ts +8 -1
- package/src/channels/adapters/kakaotalk.ts +19 -11
- package/src/channels/adapters/slack-bot-classify.ts +30 -14
- package/src/channels/adapters/slack-bot.ts +3 -2
- package/src/channels/adapters/telegram-bot-classify.ts +36 -13
- package/src/channels/adapters/telegram-bot.ts +3 -3
- package/src/channels/outbound-flood-filter.ts +57 -0
- package/src/channels/router.ts +93 -5
- package/src/channels/types.ts +52 -1
- package/src/cli/builtins.ts +2 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/mount.ts +157 -0
- package/src/cli/update.ts +84 -0
- package/src/config/mounts-mutation.ts +161 -0
- package/src/init/hatching.ts +1 -1
- package/src/plugin/index.ts +6 -0
- package/src/plugin/load-skill.ts +99 -0
- package/src/run/bundled-plugins.ts +2 -0
- package/src/run/index.ts +14 -1
- package/src/secrets/codex-auth-json.ts +67 -0
- package/src/secrets/export-codex-auth-file.ts +243 -0
- package/src/secrets/index.ts +6 -0
- package/src/server/command-runner.ts +2 -1
- package/src/server/index.ts +3 -2
- package/src/shared/index.ts +7 -1
- package/src/shared/local-time.ts +32 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
- package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
- package/src/update/index.ts +155 -0
|
@@ -26,9 +26,15 @@ import type {
|
|
|
26
26
|
OutboundMessage,
|
|
27
27
|
ResolvedChannelNames,
|
|
28
28
|
SendResult,
|
|
29
|
+
InboundAttachment,
|
|
29
30
|
} from '@/channels/types'
|
|
30
31
|
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
emoticonEventToMessageEvent,
|
|
34
|
+
splitEmoticonInbound,
|
|
35
|
+
splitHistoryInbound,
|
|
36
|
+
splitInbound,
|
|
37
|
+
} from './kakaotalk-attachment'
|
|
32
38
|
import { createKakaoAuthorResolver, type KakaoAuthorResolver } from './kakaotalk-author-resolver'
|
|
33
39
|
import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaotalk-channel-resolver'
|
|
34
40
|
import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
|
|
@@ -252,11 +258,13 @@ export function createKakaoHistoryCallback(deps: {
|
|
|
252
258
|
messages.map(async (m) => {
|
|
253
259
|
const authorId = String(m.author_id)
|
|
254
260
|
const authorName = m.author_name ?? (await authorResolver.resolve(authorId, args.chat)) ?? authorId
|
|
261
|
+
const { text, attachments } = splitHistoryInbound(m)
|
|
255
262
|
return {
|
|
256
263
|
externalMessageId: m.log_id,
|
|
257
264
|
authorId,
|
|
258
265
|
authorName,
|
|
259
|
-
text
|
|
266
|
+
text,
|
|
267
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
260
268
|
ts: m.sent_at * 1000,
|
|
261
269
|
isBot: selfId !== null && authorId === selfId,
|
|
262
270
|
replyToBotMessageId: null,
|
|
@@ -331,13 +339,8 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
331
339
|
const fetchAttachmentCallback = createFetchAttachmentCallback({ logger })
|
|
332
340
|
|
|
333
341
|
const handleMessageEvent = async (event: KakaoTalkPushMessageEvent): Promise<void> => {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
// drop and reach the agent with a `[KakaoTalk message with ...]`
|
|
337
|
-
// placeholder. For text-only messages this is a no-op —
|
|
338
|
-
// formatInboundText returns event.message unchanged. See
|
|
339
|
-
// kakaotalk-attachment.ts for the per-message-type rules.
|
|
340
|
-
await processInbound({ ...event, message: formatInboundText(event) })
|
|
342
|
+
const { text, attachments } = splitInbound(event)
|
|
343
|
+
await processInbound({ ...event, message: text }, attachments)
|
|
341
344
|
}
|
|
342
345
|
|
|
343
346
|
const handleEmoticonEvent = async (event: KakaoTalkPushEmoticonEvent): Promise<void> => {
|
|
@@ -347,10 +350,14 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
347
350
|
// self-author / unknown-chat rules apply identically across plain
|
|
348
351
|
// messages and stickers — there is no second classifier to keep in
|
|
349
352
|
// sync.
|
|
350
|
-
|
|
353
|
+
const { attachments } = splitEmoticonInbound(event)
|
|
354
|
+
await processInbound(emoticonEventToMessageEvent(event), attachments)
|
|
351
355
|
}
|
|
352
356
|
|
|
353
|
-
const processInbound = async (
|
|
357
|
+
const processInbound = async (
|
|
358
|
+
event: KakaoTalkPushMessageEvent,
|
|
359
|
+
attachments: readonly InboundAttachment[] = [],
|
|
360
|
+
): Promise<void> => {
|
|
354
361
|
inflightInbounds++
|
|
355
362
|
try {
|
|
356
363
|
if (channelResolver.lookupChat(event.chat_id) === null) {
|
|
@@ -391,6 +398,7 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
391
398
|
const verdict = classifyInbound(event, options.configRef(), {
|
|
392
399
|
selfUserId,
|
|
393
400
|
lookupChat: (id) => channelResolver.lookupChat(id),
|
|
401
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
394
402
|
...(options.selfAliasesRef ? { selfAliases: options.selfAliasesRef() } : {}),
|
|
395
403
|
})
|
|
396
404
|
if (verdict.kind === 'drop') {
|
|
@@ -2,7 +2,7 @@ import type { SlackFile, SlackSocketModeAppMentionEvent, SlackSocketModeMessageE
|
|
|
2
2
|
|
|
3
3
|
import { matchesAnyAlias } from '@/channels/engagement'
|
|
4
4
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
5
|
-
import type { InboundMessage } from '@/channels/types'
|
|
5
|
+
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
6
6
|
|
|
7
7
|
import { slackTsToMillis } from './slack-bot-time'
|
|
8
8
|
|
|
@@ -61,7 +61,7 @@ export function classifyInbound(
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const rawText = event.text ?? ''
|
|
64
|
-
const text =
|
|
64
|
+
const { text, attachments } = splitInbound(event)
|
|
65
65
|
if (text === '') return { kind: 'drop', reason: 'empty_text' }
|
|
66
66
|
|
|
67
67
|
const isDm = event.channel_type === 'im'
|
|
@@ -72,8 +72,8 @@ export function classifyInbound(
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// Mention parsing runs against the raw user-typed text only — the
|
|
75
|
-
// appended `[Slack
|
|
76
|
-
//
|
|
75
|
+
// appended `[Slack attachment #N: ...]` placeholder contains metadata
|
|
76
|
+
// that must not be misread as mentions or group broadcasts.
|
|
77
77
|
// Group mentions (`<!here>`, `<!channel>`, `<!everyone>`) are coerced to
|
|
78
78
|
// direct mentions: the user fired a broadcast that explicitly includes the
|
|
79
79
|
// bot, and from the engagement layer's perspective there is no meaningful
|
|
@@ -131,6 +131,7 @@ export function classifyInbound(
|
|
|
131
131
|
chat: event.channel,
|
|
132
132
|
thread,
|
|
133
133
|
text,
|
|
134
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
134
135
|
externalMessageId: event.ts,
|
|
135
136
|
authorId: event.user,
|
|
136
137
|
authorName: event.user,
|
|
@@ -166,19 +167,34 @@ function extractMentionedUserIds(text: string): string[] {
|
|
|
166
167
|
return Array.from(seen)
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
|
|
170
|
+
type SplitInbound = { text: string; attachments: InboundAttachment[] }
|
|
171
|
+
|
|
172
|
+
function splitInbound(event: SlackInboundMessageEvent): SplitInbound {
|
|
170
173
|
const rawText = event.text ?? ''
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
const summary =
|
|
174
|
-
|
|
174
|
+
const attachments = describeSlackMedia(event)
|
|
175
|
+
if (attachments.length === 0) return { text: rawText, attachments: [] }
|
|
176
|
+
const summary = attachments.map(renderPlaceholder).join('\n')
|
|
177
|
+
const text = rawText === '' ? summary : `${rawText}\n${summary}`
|
|
178
|
+
return { text, attachments }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function describeSlackMedia(event: SlackInboundMessageEvent): InboundAttachment[] {
|
|
182
|
+
return (event.files ?? []).map((file, index) => describeSlackFile(file, index + 1))
|
|
175
183
|
}
|
|
176
184
|
|
|
177
|
-
function
|
|
178
|
-
return
|
|
185
|
+
function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
|
|
186
|
+
return {
|
|
187
|
+
id,
|
|
188
|
+
kind: 'file',
|
|
189
|
+
ref: file.id,
|
|
190
|
+
filename: file.name,
|
|
191
|
+
mimetype: file.mimetype,
|
|
192
|
+
}
|
|
179
193
|
}
|
|
180
194
|
|
|
181
|
-
function
|
|
182
|
-
const parts: string[] = [`attachment
|
|
183
|
-
|
|
195
|
+
function renderPlaceholder(attachment: InboundAttachment): string {
|
|
196
|
+
const parts: string[] = [`Slack attachment #${attachment.id}: ${attachment.kind}`]
|
|
197
|
+
if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
|
|
198
|
+
if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
|
|
199
|
+
return `[${parts.join(' ')}]`
|
|
184
200
|
}
|
|
@@ -692,8 +692,9 @@ export function createOutboundCallback(deps: {
|
|
|
692
692
|
// plain HTTP tool. Routing through the SDK's `downloadFile(fileId)` is
|
|
693
693
|
// the only path that works — it issues `files.info` to fetch metadata
|
|
694
694
|
// (mimetype + name) then GETs `url_private` with the bot token. The
|
|
695
|
-
//
|
|
696
|
-
//
|
|
695
|
+
// classifiers now keep the bare `Fxxxx` id in structured InboundAttachment.ref
|
|
696
|
+
// (legacy persisted state may still carry the old prompt-visible `id=` shape,
|
|
697
|
+
// which channel_fetch_attachment strips before reaching this callback).
|
|
697
698
|
export function createFetchAttachmentCallback(deps: {
|
|
698
699
|
client: Pick<SlackBotClient, 'downloadFile'>
|
|
699
700
|
logger: SlackBotAdapterLogger
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { TelegramBotUser, TelegramMessage, TelegramMessageEntity } from 'agent-messenger/telegrambot'
|
|
2
2
|
|
|
3
3
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
4
|
-
import type { InboundMessage } from '@/channels/types'
|
|
4
|
+
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
5
5
|
|
|
6
6
|
export type InboundDropReason = 'self_author' | 'no_user' | 'empty_text' | 'pre_connect'
|
|
7
7
|
|
|
@@ -31,7 +31,7 @@ export function classifyInbound(
|
|
|
31
31
|
return { kind: 'drop', reason: 'self_author' }
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const text =
|
|
34
|
+
const { text, attachments } = splitInbound(event)
|
|
35
35
|
if (text === '') return { kind: 'drop', reason: 'empty_text' }
|
|
36
36
|
|
|
37
37
|
const chat = String(event.chat.id)
|
|
@@ -70,6 +70,7 @@ export function classifyInbound(
|
|
|
70
70
|
chat,
|
|
71
71
|
thread,
|
|
72
72
|
text,
|
|
73
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
73
74
|
externalMessageId: String(event.message_id),
|
|
74
75
|
authorId: String(author.id),
|
|
75
76
|
authorName: formatAuthorName(author),
|
|
@@ -130,24 +131,46 @@ function isUserMentionForBot(
|
|
|
130
131
|
return false
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
|
|
134
|
+
type SplitInbound = { text: string; attachments: InboundAttachment[] }
|
|
135
|
+
|
|
136
|
+
function splitInbound(event: TelegramMessage): SplitInbound {
|
|
134
137
|
const body = event.text ?? event.caption ?? ''
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
const summary =
|
|
138
|
-
|
|
138
|
+
const attachments = describeMedia(event)
|
|
139
|
+
if (attachments.length === 0) return { text: body, attachments: [] }
|
|
140
|
+
const summary = attachments.map(renderPlaceholder).join('\n')
|
|
141
|
+
const text = body === '' ? summary : `${body}\n${summary}`
|
|
142
|
+
return { text, attachments }
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
function
|
|
142
|
-
const parts:
|
|
145
|
+
function describeMedia(event: TelegramMessage): InboundAttachment[] {
|
|
146
|
+
const parts: InboundAttachment[] = []
|
|
143
147
|
if (event.document !== undefined) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
148
|
+
parts.push({
|
|
149
|
+
id: parts.length + 1,
|
|
150
|
+
kind: 'file',
|
|
151
|
+
ref: event.document.file_id,
|
|
152
|
+
...(event.document.file_name !== undefined ? { filename: event.document.file_name } : {}),
|
|
153
|
+
...(event.document.mime_type !== undefined ? { mimetype: event.document.mime_type } : {}),
|
|
154
|
+
})
|
|
147
155
|
}
|
|
148
156
|
if (event.photo !== undefined && event.photo.length > 0) {
|
|
149
157
|
const largest = event.photo[event.photo.length - 1]!
|
|
150
|
-
parts.push(
|
|
158
|
+
parts.push({
|
|
159
|
+
id: parts.length + 1,
|
|
160
|
+
kind: 'photo',
|
|
161
|
+
ref: largest.file_id,
|
|
162
|
+
width: largest.width,
|
|
163
|
+
height: largest.height,
|
|
164
|
+
})
|
|
151
165
|
}
|
|
152
166
|
return parts
|
|
153
167
|
}
|
|
168
|
+
|
|
169
|
+
function renderPlaceholder(attachment: InboundAttachment): string {
|
|
170
|
+
const parts: string[] = [`Telegram attachment #${attachment.id}: ${attachment.kind}`]
|
|
171
|
+
if (attachment.width !== undefined && attachment.height !== undefined)
|
|
172
|
+
parts.push(`${attachment.width}x${attachment.height}`)
|
|
173
|
+
if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
|
|
174
|
+
if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
|
|
175
|
+
return `[${parts.join(' ')}]`
|
|
176
|
+
}
|
|
@@ -279,9 +279,9 @@ type TelegramFileResponse = {
|
|
|
279
279
|
// Telegram's file download is a two-step protocol: `getFile` returns a
|
|
280
280
|
// short-lived `file_path`, then the file lives at
|
|
281
281
|
// `api.telegram.org/file/bot<TOKEN>/<file_path>`. `ref` here is the
|
|
282
|
-
// `file_id` carried in
|
|
283
|
-
//
|
|
284
|
-
//
|
|
282
|
+
// `file_id` carried in structured InboundAttachment.ref. The agent only sees
|
|
283
|
+
// `[Telegram attachment #N: ...]` and passes that id through the
|
|
284
|
+
// `channel_fetch_attachment` tool; the router resolves it to this callback.
|
|
285
285
|
//
|
|
286
286
|
// SSRF boundary: `ref` is `encodeURIComponent`'d into a query parameter
|
|
287
287
|
// of a fixed `api.telegram.org/bot<TOKEN>/getFile?file_id=...` URL, so
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type OutboundFloodCheckResult = { ok: true } | { ok: false; reason: string }
|
|
2
|
+
|
|
3
|
+
const MIN_LENGTH = 40
|
|
4
|
+
const MAX_RUN = 30
|
|
5
|
+
const MIN_LONG_LENGTH = 80
|
|
6
|
+
const MIN_UNIQUE_RATIO = 0.05
|
|
7
|
+
const MAX_DOMINANCE = 0.9
|
|
8
|
+
|
|
9
|
+
export function checkOutboundFlood(text: string): OutboundFloodCheckResult {
|
|
10
|
+
if (text.length < MIN_LENGTH) return { ok: true }
|
|
11
|
+
|
|
12
|
+
const graphemes = Array.from(text.normalize('NFKC'))
|
|
13
|
+
if (graphemes.length < MIN_LENGTH) return { ok: true }
|
|
14
|
+
|
|
15
|
+
const longestRun = findLongestRun(graphemes)
|
|
16
|
+
if (longestRun >= MAX_RUN) return { ok: false, reason: `repeated-char-run:${longestRun}` }
|
|
17
|
+
|
|
18
|
+
if (graphemes.length < MIN_LONG_LENGTH) return { ok: true }
|
|
19
|
+
|
|
20
|
+
const counts = countGraphemes(graphemes)
|
|
21
|
+
const uniqueRatio = counts.size / graphemes.length
|
|
22
|
+
if (uniqueRatio < MIN_UNIQUE_RATIO) return { ok: false, reason: `low-unique-ratio:${uniqueRatio.toFixed(3)}` }
|
|
23
|
+
|
|
24
|
+
const dominance = maxValue(counts) / graphemes.length
|
|
25
|
+
if (dominance > MAX_DOMINANCE) return { ok: false, reason: `char-dominance:${dominance.toFixed(2)}` }
|
|
26
|
+
|
|
27
|
+
return { ok: true }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function findLongestRun(graphemes: readonly string[]): number {
|
|
31
|
+
if (graphemes.length === 0) return 0
|
|
32
|
+
let longest = 1
|
|
33
|
+
let current = 1
|
|
34
|
+
for (let i = 1; i < graphemes.length; i++) {
|
|
35
|
+
if (graphemes[i] === graphemes[i - 1]) {
|
|
36
|
+
current++
|
|
37
|
+
if (current > longest) longest = current
|
|
38
|
+
} else {
|
|
39
|
+
current = 1
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return longest
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function countGraphemes(graphemes: readonly string[]): Map<string, number> {
|
|
46
|
+
const counts = new Map<string, number>()
|
|
47
|
+
for (const grapheme of graphemes) counts.set(grapheme, (counts.get(grapheme) ?? 0) + 1)
|
|
48
|
+
return counts
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function maxValue(counts: Map<string, number>): number {
|
|
52
|
+
let max = 0
|
|
53
|
+
for (const value of counts.values()) {
|
|
54
|
+
if (value > max) max = value
|
|
55
|
+
}
|
|
56
|
+
return max
|
|
57
|
+
}
|
package/src/channels/router.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
|
|
|
3
3
|
import type { AssistantMessage } from '@mariozechner/pi-ai'
|
|
4
4
|
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
5
5
|
|
|
6
|
-
import { createSession, type AgentSession } from '@/agent'
|
|
6
|
+
import { createSession, renderTurnTimeAnchor, type AgentSession } from '@/agent'
|
|
7
7
|
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
8
8
|
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
9
9
|
import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type MembershipResolverResult,
|
|
22
22
|
} from './membership'
|
|
23
23
|
import { createMembershipCache, type MembershipCache } from './membership-cache'
|
|
24
|
+
import { checkOutboundFlood } from './outbound-flood-filter'
|
|
24
25
|
import { updateParticipants } from './participants'
|
|
25
26
|
import {
|
|
26
27
|
channelsSessionsPath,
|
|
@@ -40,6 +41,7 @@ import type {
|
|
|
40
41
|
FetchHistoryArgs,
|
|
41
42
|
FetchHistoryResult,
|
|
42
43
|
HistoryCallback,
|
|
44
|
+
InboundAttachment,
|
|
43
45
|
InboundMessage,
|
|
44
46
|
OutboundCallback,
|
|
45
47
|
OutboundMessage,
|
|
@@ -106,6 +108,7 @@ export const SEND_RATE_WINDOW_MS = 5_000
|
|
|
106
108
|
// send still emits a structured log line regardless of rate — this
|
|
107
109
|
// constant only controls when the warning marker appears.
|
|
108
110
|
export const SEND_RATE_WARN_THRESHOLD = 3
|
|
111
|
+
export const OUTBOUND_FLOOD_ERROR = 'outbound message denied: content looks like a repeated-character flood'
|
|
109
112
|
|
|
110
113
|
/**
|
|
111
114
|
* Maximum age of the last engaged inbound before the next inbound triggers a fresh session.
|
|
@@ -216,6 +219,7 @@ export type ConfigForAdapter = (adapter: ChannelKey['adapter']) => ChannelAdapte
|
|
|
216
219
|
|
|
217
220
|
type QueuedInbound = {
|
|
218
221
|
text: string
|
|
222
|
+
attachments?: readonly InboundAttachment[]
|
|
219
223
|
authorId: string
|
|
220
224
|
authorName: string
|
|
221
225
|
authorIsBot: boolean
|
|
@@ -234,6 +238,7 @@ type QueuedInbound = {
|
|
|
234
238
|
|
|
235
239
|
type ObservedInbound = {
|
|
236
240
|
text: string
|
|
241
|
+
attachments?: readonly InboundAttachment[]
|
|
237
242
|
authorId: string
|
|
238
243
|
authorName: string
|
|
239
244
|
authorIsBot: boolean
|
|
@@ -447,6 +452,8 @@ export type ChannelRouter = {
|
|
|
447
452
|
registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
|
|
448
453
|
unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
|
|
449
454
|
fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
|
|
455
|
+
lookupInboundAttachment: (args: ChannelKey & { id: number }) => InboundAttachment | null
|
|
456
|
+
listInboundAttachmentIds: (args: ChannelKey) => readonly number[]
|
|
450
457
|
// Execute a command by name against an existing live session, bypassing
|
|
451
458
|
// the inbound classifier, engagement gate, debounce, and prompt queue.
|
|
452
459
|
// Used by adapters that receive commands through a native surface
|
|
@@ -1635,6 +1642,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1635
1642
|
const observe = (live: LiveSession, event: InboundMessage): void => {
|
|
1636
1643
|
live.contextBuffer.push({
|
|
1637
1644
|
text: event.text,
|
|
1645
|
+
...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
|
|
1638
1646
|
authorId: event.authorId,
|
|
1639
1647
|
authorName: event.authorName,
|
|
1640
1648
|
authorIsBot: event.authorIsBot,
|
|
@@ -1650,6 +1658,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1650
1658
|
const enqueue = (live: LiveSession, event: InboundMessage): void => {
|
|
1651
1659
|
live.promptQueue.push({
|
|
1652
1660
|
text: event.text,
|
|
1661
|
+
...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
|
|
1653
1662
|
authorId: event.authorId,
|
|
1654
1663
|
authorName: event.authorName,
|
|
1655
1664
|
authorIsBot: event.authorIsBot,
|
|
@@ -1798,6 +1807,39 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1798
1807
|
return lastError
|
|
1799
1808
|
}
|
|
1800
1809
|
|
|
1810
|
+
const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
|
|
1811
|
+
const live = liveSessions.get(channelKeyId(args))
|
|
1812
|
+
if (live === undefined) return null
|
|
1813
|
+
// Walk newest → oldest so that when an id collides across messages
|
|
1814
|
+
// (e.g. two photos in the same session each labelled `#1`) the agent's
|
|
1815
|
+
// `attachment_id: 1` always resolves to the CURRENT inbound's
|
|
1816
|
+
// attachment. promptQueue holds the about-to-be-delivered turn and
|
|
1817
|
+
// is therefore the freshest; within each list, append-order maps to
|
|
1818
|
+
// wall-clock order, so iterating in reverse gives recency.
|
|
1819
|
+
const haystacks: ReadonlyArray<ReadonlyArray<{ attachments?: readonly InboundAttachment[] }>> = [
|
|
1820
|
+
live.promptQueue,
|
|
1821
|
+
live.contextBuffer,
|
|
1822
|
+
]
|
|
1823
|
+
for (const haystack of haystacks) {
|
|
1824
|
+
for (let i = haystack.length - 1; i >= 0; i--) {
|
|
1825
|
+
const item = haystack[i]
|
|
1826
|
+
const found = item?.attachments?.find((attachment) => attachment.id === args.id)
|
|
1827
|
+
if (found !== undefined) return found
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
return null
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
const listInboundAttachmentIds = (args: ChannelKey): readonly number[] => {
|
|
1834
|
+
const live = liveSessions.get(channelKeyId(args))
|
|
1835
|
+
if (live === undefined) return []
|
|
1836
|
+
const ids = new Set<number>()
|
|
1837
|
+
for (const item of [...live.promptQueue, ...live.contextBuffer]) {
|
|
1838
|
+
for (const attachment of item.attachments ?? []) ids.add(attachment.id)
|
|
1839
|
+
}
|
|
1840
|
+
return Array.from(ids).sort((a, b) => a - b)
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1801
1843
|
const send = async (msg: OutboundMessage, opts?: SendOptions): Promise<SendResult> => {
|
|
1802
1844
|
const source: SendSource = opts?.source ?? 'tool'
|
|
1803
1845
|
const callbacks = outboundCallbacks.get(msg.adapter)
|
|
@@ -1805,6 +1847,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1805
1847
|
return { ok: false, error: `no adapter registered for "${msg.adapter}"`, code: 'no-adapter' }
|
|
1806
1848
|
}
|
|
1807
1849
|
|
|
1850
|
+
const authoredText = normalizeSendText(msg.text)
|
|
1851
|
+
if (authoredText !== undefined) {
|
|
1852
|
+
const flood = checkOutboundFlood(authoredText)
|
|
1853
|
+
if (!flood.ok) return { ok: false, error: OUTBOUND_FLOOD_ERROR, code: 'outbound-flood' }
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1808
1856
|
const keyId = channelKeyId({
|
|
1809
1857
|
adapter: msg.adapter,
|
|
1810
1858
|
workspace: msg.workspace,
|
|
@@ -1982,6 +2030,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1982
2030
|
return
|
|
1983
2031
|
}
|
|
1984
2032
|
|
|
2033
|
+
if (isLikelyPlainTextChannelToolCall(assistantText)) {
|
|
2034
|
+
logger.warn(`[channels] ${live.keyId}: suppressed plain_text_channel_tool_call text_len=${assistantText.length}`)
|
|
2035
|
+
return
|
|
2036
|
+
}
|
|
2037
|
+
|
|
1985
2038
|
// `source` distinguishes the two recovery shapes for log triage:
|
|
1986
2039
|
// - 'leaf': the assistant message IS the leaf (existing behavior; model
|
|
1987
2040
|
// ended its turn with text but forgot to call channel_reply).
|
|
@@ -2234,6 +2287,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2234
2287
|
registerFetchAttachment,
|
|
2235
2288
|
unregisterFetchAttachment,
|
|
2236
2289
|
fetchAttachment,
|
|
2290
|
+
lookupInboundAttachment,
|
|
2291
|
+
listInboundAttachmentIds,
|
|
2237
2292
|
executeCommand,
|
|
2238
2293
|
getSelfAliases: computeSelfAliases,
|
|
2239
2294
|
injectSubagentCompletionReminder,
|
|
@@ -2306,12 +2361,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2306
2361
|
function composeTurnPrompt(
|
|
2307
2362
|
observed: readonly ObservedInbound[],
|
|
2308
2363
|
batch: readonly QueuedInbound[],
|
|
2309
|
-
state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[] } = {
|
|
2364
|
+
state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[]; now?: Date } = {
|
|
2310
2365
|
loopGuardActive: false,
|
|
2311
2366
|
},
|
|
2312
2367
|
): string {
|
|
2313
2368
|
const adapter = state.adapter ?? 'discord-bot'
|
|
2314
2369
|
const parts: string[] = []
|
|
2370
|
+
parts.push(renderTurnTimeAnchor(state.now), '')
|
|
2315
2371
|
// System reminders (subagent-completion wakeups today) lead the turn body
|
|
2316
2372
|
// because they are typically what triggered the drain — when the prompt
|
|
2317
2373
|
// queue is empty and the only thing in this iteration is a reminder, the
|
|
@@ -2503,18 +2559,20 @@ export type QuoteAnchorCandidate = {
|
|
|
2503
2559
|
hadInterveningObserved: boolean
|
|
2504
2560
|
}
|
|
2505
2561
|
|
|
2506
|
-
// Strips `[<Adapter>
|
|
2562
|
+
// Strips both current `[<Adapter> attachment #N: ...]` and legacy
|
|
2563
|
+
// `[<Adapter> message with ...]` placeholders that adapter
|
|
2507
2564
|
// classifiers synthesize for non-text inbounds (KakaoTalk stickers,
|
|
2508
2565
|
// Slack/Discord/Telegram attachments). The quote anchor is a UX
|
|
2509
2566
|
// affordance pointing the human at *their words* — quoting a sticker as
|
|
2510
|
-
// `> Alice: [KakaoTalk
|
|
2567
|
+
// `> Alice: [KakaoTalk attachment #1: sticker name=...]`
|
|
2511
2568
|
// is noise, and for mixed inbounds like `사진 [KakaoTalk message with
|
|
2512
2569
|
// photo 1254x1254 ...]` the human only wrote `사진`, so the placeholder
|
|
2513
2570
|
// is the wrong thing to surface. The callsite (captureQuoteCandidate)
|
|
2514
2571
|
// treats an empty residue as "no quote anchor"; mixed inbounds keep the
|
|
2515
2572
|
// human-written portion. renderQuoteAnchor later collapses whitespace
|
|
2516
2573
|
// so residual double-spaces from mid-string strips are harmless.
|
|
2517
|
-
const CHANNEL_MEDIA_PLACEHOLDER_RE =
|
|
2574
|
+
const CHANNEL_MEDIA_PLACEHOLDER_RE =
|
|
2575
|
+
/\[(?:KakaoTalk|Slack|Discord|Telegram) (?:message with|attachment #\d+:) [^\]]*\]/g
|
|
2518
2576
|
|
|
2519
2577
|
export function stripChannelMediaPlaceholders(text: string): string {
|
|
2520
2578
|
return text
|
|
@@ -2944,6 +3002,36 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
|
|
|
2944
3002
|
return KIMI_CHANNEL_TOOL_ID_RE.test(text)
|
|
2945
3003
|
}
|
|
2946
3004
|
|
|
3005
|
+
// Detects the *plain-text* shape of a leaked channel-tool invocation — the
|
|
3006
|
+
// model serialized the tool call as ordinary prose instead of producing a
|
|
3007
|
+
// real tool call. Observed against Kimi-family deployments on KakaoTalk:
|
|
3008
|
+
// the entire assistant message body is literally
|
|
3009
|
+
//
|
|
3010
|
+
// channel_reply({"text":"<the user-facing greeting the bot meant to send>"})
|
|
3011
|
+
//
|
|
3012
|
+
// with no Kimi delimiter tokens (`<|tool_call_begin|>` etc.), so
|
|
3013
|
+
// `isLikelyKimiChannelToolLeak` cannot catch it. Without a guard the
|
|
3014
|
+
// recovery path in `validateChannelTurn` posts this raw function-call
|
|
3015
|
+
// serialization straight to the channel, which is exactly what
|
|
3016
|
+
// users see in the reported screenshots.
|
|
3017
|
+
//
|
|
3018
|
+
// Structural-only detection (NOT a substring search): the trimmed text must
|
|
3019
|
+
// *start* with `channel_reply(` or `channel_send(`, and that opening paren
|
|
3020
|
+
// must enclose at least one `"` (the JSON argument). This deliberately
|
|
3021
|
+
// matches the leak shape while letting prose that merely *mentions* the
|
|
3022
|
+
// tool name (e.g. "I would normally call channel_reply here but...") reach
|
|
3023
|
+
// the user — that false-positive class is already locked in by the
|
|
3024
|
+
// `still recovers legit prose that happens to mention "channel_reply"` test.
|
|
3025
|
+
//
|
|
3026
|
+
// The trailing close paren is NOT required: the model sometimes truncates
|
|
3027
|
+
// mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
|
|
3028
|
+
// just as user-hostile as the full shape.
|
|
3029
|
+
const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^channel_(?:reply|send)\s*\(\s*[^)]*"/
|
|
3030
|
+
|
|
3031
|
+
export function isLikelyPlainTextChannelToolCall(text: string): boolean {
|
|
3032
|
+
return PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.test(text.trim())
|
|
3033
|
+
}
|
|
3034
|
+
|
|
2947
3035
|
function describe(err: unknown): string {
|
|
2948
3036
|
return err instanceof Error ? err.message : String(err)
|
|
2949
3037
|
}
|
package/src/channels/types.ts
CHANGED
|
@@ -7,12 +7,56 @@ export type ChannelKey = {
|
|
|
7
7
|
thread: string | null
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
// Inbound (non-text) media that the user attached to a channel message.
|
|
11
|
+
// The classifier produces these alongside `InboundMessage.text`; the router
|
|
12
|
+
// stores them and lets channel tools look them up by `id` so the agent can
|
|
13
|
+
// fetch / view a specific attachment without ever seeing the underlying
|
|
14
|
+
// platform-side `ref` (URL, file id, CDN key) in its prompt context.
|
|
15
|
+
//
|
|
16
|
+
// Design contract:
|
|
17
|
+
// - `id` is a 1-based index that is stable WITHIN A SINGLE inbound message
|
|
18
|
+
// and assigned by the adapter classifier. It is NOT globally unique —
|
|
19
|
+
// different inbounds re-use small ids (1, 2, ...). The router's lookup
|
|
20
|
+
// scopes the search to one (adapter,workspace,chat,thread) session and
|
|
21
|
+
// returns the MOST RECENT match across that session's promptQueue +
|
|
22
|
+
// contextBuffer, so within a single turn the agent always resolves
|
|
23
|
+
// `attachment_id: 1` to the attachment on the current inbound — earlier
|
|
24
|
+
// uses of id 1 from buffered context cannot intercept the lookup.
|
|
25
|
+
// - `ref` is the opaque platform handle that the adapter's
|
|
26
|
+
// FetchAttachmentCallback knows how to download (Slack file id, Discord
|
|
27
|
+
// CDN URL, KakaoCDN URL, Telegram file_id). It is INTENTIONALLY not
|
|
28
|
+
// rendered into the user-visible prompt text — keeping it out of the
|
|
29
|
+
// LLM's context prevents the dialect-confusion bug where the agent
|
|
30
|
+
// pastes a malformed ref (e.g. a KakaoCDN bare key) into a tool.
|
|
31
|
+
// - The kind labels (photo/video/...) are coarse on purpose: they exist
|
|
32
|
+
// for the prompt placeholder ("an image arrived") and for tool routing,
|
|
33
|
+
// not for platform-specific behavior.
|
|
34
|
+
export type InboundAttachment = {
|
|
35
|
+
id: number
|
|
36
|
+
kind: 'photo' | 'video' | 'audio' | 'file' | 'sticker' | 'multiphoto' | 'embed'
|
|
37
|
+
ref: string
|
|
38
|
+
// Optional metadata that the adapter classifier may surface for the
|
|
39
|
+
// placeholder rendering. Every field MUST be safe to print into a prompt
|
|
40
|
+
// (no credentials, no long opaque tokens). If a piece of metadata would
|
|
41
|
+
// leak fetchable state, leave it off and rely on `ref` instead.
|
|
42
|
+
mimetype?: string
|
|
43
|
+
filename?: string
|
|
44
|
+
width?: number
|
|
45
|
+
height?: number
|
|
46
|
+
sizeBytes?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
10
49
|
export type InboundMessage = {
|
|
11
50
|
adapter: AdapterId
|
|
12
51
|
workspace: string
|
|
13
52
|
chat: string
|
|
14
53
|
thread: string | null
|
|
15
54
|
text: string
|
|
55
|
+
// Non-text attachments the user sent on this inbound. Empty / omitted
|
|
56
|
+
// when the message is text-only. The router carries these through to
|
|
57
|
+
// the live session's promptQueue/contextBuffer so channel tools can
|
|
58
|
+
// resolve `attachment_id` → ref without the agent ever seeing the ref.
|
|
59
|
+
attachments?: readonly InboundAttachment[]
|
|
16
60
|
externalMessageId: string
|
|
17
61
|
authorId: string
|
|
18
62
|
authorName: string
|
|
@@ -84,7 +128,13 @@ export type OutboundMessage = {
|
|
|
84
128
|
attachments?: OutboundAttachment[]
|
|
85
129
|
}
|
|
86
130
|
|
|
87
|
-
export type SendErrorCode =
|
|
131
|
+
export type SendErrorCode =
|
|
132
|
+
| 'duplicate'
|
|
133
|
+
| 'turn-cap'
|
|
134
|
+
| 'outbound-flood'
|
|
135
|
+
| 'no-adapter'
|
|
136
|
+
| 'callback-rejected'
|
|
137
|
+
| 'skip-locked'
|
|
88
138
|
|
|
89
139
|
export type SendResult = { ok: true } | { ok: false; error: string; code?: SendErrorCode }
|
|
90
140
|
|
|
@@ -124,6 +174,7 @@ export type ChannelHistoryMessage = {
|
|
|
124
174
|
authorId: string
|
|
125
175
|
authorName: string
|
|
126
176
|
text: string
|
|
177
|
+
attachments?: readonly InboundAttachment[]
|
|
127
178
|
ts: number
|
|
128
179
|
isBot: boolean
|
|
129
180
|
replyToBotMessageId: string | null
|
package/src/cli/builtins.ts
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -31,8 +31,10 @@ const main = defineCommand({
|
|
|
31
31
|
role: () => import('./role').then((m) => m.roleCommand),
|
|
32
32
|
provider: () => import('./provider').then((m) => m.providerCommand),
|
|
33
33
|
model: () => import('./model').then((m) => m.modelCommand),
|
|
34
|
+
mount: () => import('./mount').then((m) => m.mountCommand),
|
|
34
35
|
doctor: () => import('./doctor').then((m) => m.doctorCommand),
|
|
35
36
|
usage: () => import('./usage').then((m) => m.usageCommand),
|
|
37
|
+
update: () => import('./update').then((m) => m.updateCommand),
|
|
36
38
|
_hostd: () => import('./hostd').then((m) => m.hostdCommand),
|
|
37
39
|
},
|
|
38
40
|
})
|