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.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/scripts/dump-system-prompt.ts +12 -11
  4. package/src/agent/index.ts +15 -22
  5. package/src/agent/loop-guard.ts +170 -0
  6. package/src/agent/model-fallback.ts +2 -1
  7. package/src/agent/multimodal/index.ts +1 -1
  8. package/src/agent/multimodal/look-at.ts +118 -55
  9. package/src/agent/plugin-tools.ts +57 -0
  10. package/src/agent/subagents.ts +2 -1
  11. package/src/agent/system-prompt.ts +28 -25
  12. package/src/agent/tools/channel-fetch-attachment.ts +45 -16
  13. package/src/agent/tools/normalize-ref.ts +11 -0
  14. package/src/bundled-plugins/reviewer/index.ts +11 -0
  15. package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
  16. package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
  17. package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
  18. package/src/channels/adapters/discord-bot-classify.ts +32 -24
  19. package/src/channels/adapters/github/inbound.ts +19 -2
  20. package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
  21. package/src/channels/adapters/kakaotalk-classify.ts +8 -1
  22. package/src/channels/adapters/kakaotalk.ts +19 -11
  23. package/src/channels/adapters/slack-bot-classify.ts +30 -14
  24. package/src/channels/adapters/slack-bot.ts +3 -2
  25. package/src/channels/adapters/telegram-bot-classify.ts +36 -13
  26. package/src/channels/adapters/telegram-bot.ts +3 -3
  27. package/src/channels/outbound-flood-filter.ts +57 -0
  28. package/src/channels/router.ts +93 -5
  29. package/src/channels/types.ts +52 -1
  30. package/src/cli/builtins.ts +2 -0
  31. package/src/cli/index.ts +2 -0
  32. package/src/cli/mount.ts +157 -0
  33. package/src/cli/update.ts +84 -0
  34. package/src/config/mounts-mutation.ts +161 -0
  35. package/src/init/hatching.ts +1 -1
  36. package/src/plugin/index.ts +6 -0
  37. package/src/plugin/load-skill.ts +99 -0
  38. package/src/run/bundled-plugins.ts +2 -0
  39. package/src/run/index.ts +14 -1
  40. package/src/secrets/codex-auth-json.ts +67 -0
  41. package/src/secrets/export-codex-auth-file.ts +243 -0
  42. package/src/secrets/index.ts +6 -0
  43. package/src/server/command-runner.ts +2 -1
  44. package/src/server/index.ts +3 -2
  45. package/src/shared/index.ts +7 -1
  46. package/src/shared/local-time.ts +32 -0
  47. package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
  48. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
  49. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
  50. package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
  51. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
  52. package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
  53. 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 { emoticonEventToMessageEvent, formatHistoryText, formatInboundText } from './kakaotalk-attachment'
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: formatHistoryText(m),
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
- // Synthesize the displayed text BEFORE classify so attachments
335
- // (photo, file, video, ...) survive classifyInbound's empty_text
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
- await processInbound(emoticonEventToMessageEvent(event))
353
+ const { attachments } = splitEmoticonInbound(event)
354
+ await processInbound(emoticonEventToMessageEvent(event), attachments)
351
355
  }
352
356
 
353
- const processInbound = async (event: KakaoTalkPushMessageEvent): Promise<void> => {
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 = inboundText(event)
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 message with attachment: ...]` summary contains URLs
76
- // and ids that must not be misread as mentions or group broadcasts.
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
- function inboundText(event: SlackInboundMessageEvent): string {
170
+ type SplitInbound = { text: string; attachments: InboundAttachment[] }
171
+
172
+ function splitInbound(event: SlackInboundMessageEvent): SplitInbound {
170
173
  const rawText = event.text ?? ''
171
- const mediaSummary = summarizeSlackMedia(event)
172
- if (mediaSummary.length === 0) return rawText
173
- const summary = `[Slack message with ${mediaSummary.join('; ')}]`
174
- return rawText === '' ? summary : `${rawText}\n${summary}`
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 summarizeSlackMedia(event: SlackInboundMessageEvent): string[] {
178
- return (event.files ?? []).map(summarizeSlackFile)
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 summarizeSlackFile(file: SlackFile): string {
182
- const parts: string[] = [`attachment: ${file.name}`, `(${file.mimetype})`, `id=${file.id}`]
183
- return parts.join(' ')
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
- // classifier emits `id=Fxxxx` in the inbound text exactly so the agent
696
- // can hand the id back to this callback.
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 = inboundText(event)
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
- function inboundText(event: TelegramMessage): string {
134
+ type SplitInbound = { text: string; attachments: InboundAttachment[] }
135
+
136
+ function splitInbound(event: TelegramMessage): SplitInbound {
134
137
  const body = event.text ?? event.caption ?? ''
135
- const mediaSummary = summarizeMedia(event)
136
- if (mediaSummary.length === 0) return body
137
- const summary = `[Telegram message with ${mediaSummary.join('; ')}]`
138
- return body === '' ? summary : `${body}\n${summary}`
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 summarizeMedia(event: TelegramMessage): string[] {
142
- const parts: string[] = []
145
+ function describeMedia(event: TelegramMessage): InboundAttachment[] {
146
+ const parts: InboundAttachment[] = []
143
147
  if (event.document !== undefined) {
144
- const name = event.document.file_name ?? event.document.file_id
145
- const mime = event.document.mime_type !== undefined ? ` (${event.document.mime_type})` : ''
146
- parts.push(`document: ${name}${mime} file_id=${event.document.file_id}`)
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(`photo: ${largest.width}x${largest.height} file_id=${largest.file_id}`)
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 the inbound classifier's `[Telegram message with
283
- // document: ... file_id=<id>]` summary; the agent passes it back through
284
- // the `channel_fetch_attachment` tool.
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
+ }
@@ -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> message with ...]` placeholders that 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 message with sticker (sticker_ani) pack=... path=...]`
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 = /\[(?:KakaoTalk|Slack|Discord|Telegram) message with [^\]]*\]/g
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
  }
@@ -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 = 'duplicate' | 'turn-cap' | 'no-adapter' | 'callback-rejected' | 'skip-locked'
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
@@ -21,8 +21,10 @@ export const BUILTIN_COMMAND_NAMES = [
21
21
  'role',
22
22
  'provider',
23
23
  'model',
24
+ 'mount',
24
25
  'doctor',
25
26
  'usage',
27
+ 'update',
26
28
  '_hostd',
27
29
  ] as const
28
30
 
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
  })