typeclaw 0.12.0 → 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 (52) hide show
  1. package/package.json +1 -1
  2. package/scripts/dump-system-prompt.ts +12 -11
  3. package/src/agent/index.ts +15 -22
  4. package/src/agent/loop-guard.ts +170 -0
  5. package/src/agent/model-fallback.ts +2 -1
  6. package/src/agent/multimodal/index.ts +1 -1
  7. package/src/agent/multimodal/look-at.ts +118 -55
  8. package/src/agent/plugin-tools.ts +57 -0
  9. package/src/agent/subagents.ts +2 -1
  10. package/src/agent/system-prompt.ts +28 -25
  11. package/src/agent/tools/channel-fetch-attachment.ts +45 -16
  12. package/src/agent/tools/normalize-ref.ts +11 -0
  13. package/src/bundled-plugins/reviewer/index.ts +11 -0
  14. package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
  15. package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
  16. package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
  17. package/src/channels/adapters/discord-bot-classify.ts +32 -24
  18. package/src/channels/adapters/github/inbound.ts +19 -2
  19. package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
  20. package/src/channels/adapters/kakaotalk-classify.ts +8 -1
  21. package/src/channels/adapters/kakaotalk.ts +19 -11
  22. package/src/channels/adapters/slack-bot-classify.ts +30 -14
  23. package/src/channels/adapters/slack-bot.ts +3 -2
  24. package/src/channels/adapters/telegram-bot-classify.ts +36 -13
  25. package/src/channels/adapters/telegram-bot.ts +3 -3
  26. package/src/channels/outbound-flood-filter.ts +57 -0
  27. package/src/channels/router.ts +93 -5
  28. package/src/channels/types.ts +52 -1
  29. package/src/cli/builtins.ts +1 -0
  30. package/src/cli/index.ts +1 -0
  31. package/src/cli/mount.ts +157 -0
  32. package/src/cli/update.ts +6 -4
  33. package/src/config/mounts-mutation.ts +161 -0
  34. package/src/init/hatching.ts +1 -1
  35. package/src/plugin/index.ts +6 -0
  36. package/src/plugin/load-skill.ts +99 -0
  37. package/src/run/bundled-plugins.ts +2 -0
  38. package/src/run/index.ts +14 -1
  39. package/src/secrets/codex-auth-json.ts +67 -0
  40. package/src/secrets/export-codex-auth-file.ts +243 -0
  41. package/src/secrets/index.ts +6 -0
  42. package/src/server/command-runner.ts +2 -1
  43. package/src/server/index.ts +3 -2
  44. package/src/shared/index.ts +7 -1
  45. package/src/shared/local-time.ts +32 -0
  46. package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
  47. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
  48. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
  49. package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
  50. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
  51. package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
  52. package/src/update/index.ts +95 -26
@@ -0,0 +1,68 @@
1
+ import type { LoadableSkill } from '@/plugin'
2
+
3
+ export const GENERAL_REVIEW_SKILL_NAME = 'general'
4
+
5
+ export const GENERAL_REVIEW_SKILL_DESCRIPTION =
6
+ 'Fallback for review targets that do not fit a specific domain skill: a written argument, a proposal, a draft, a mixed-format artifact. Apply the universal review philosophy without domain-specific shortcuts.'
7
+
8
+ export const GENERAL_REVIEW_SKILL_CONTENT = `# general
9
+
10
+ You have been asked to review something that does not clearly fit a specific domain skill (not a code PR, not a plan, not a design doc, not docs — or it is a mix). Apply the universal review philosophy on top of the reviewer's neutral output contract.
11
+
12
+ ## How to acquire the target
13
+
14
+ - **A URL** — \`webfetch\` it. If it is a private resource the fetch cannot reach, say so in \`<summary>\` and review what was provided in the payload.
15
+ - **A file path** — \`read\` it. \`ls\` the parent directory if siblings might be relevant.
16
+ - **Inline text in the payload** — read the payload carefully; quote from it when forming evidence.
17
+ - **A reference to something the caller has** — ask the caller to provide it. Return a single \`blocker\` finding describing what you need and a \`comment\` verdict.
18
+
19
+ ## How to read carefully
20
+
21
+ A general review is the hardest because there are no domain shortcuts. Replace shortcuts with discipline:
22
+
23
+ 1. **State the target's purpose in your own words.** What is the artifact trying to achieve? Who is it for? Put this in \`<summary>\`. If you cannot state it after reading, that itself is a finding — the artifact does not communicate its purpose.
24
+ 2. **Identify the load-bearing claims.** What does the artifact assert that, if wrong, would invalidate the whole thing? List them mentally before looking for issues.
25
+ 3. **Stress-test the load-bearing claims.** For each one: is the evidence sufficient? Are the assumptions stated? Are the counter-arguments addressed?
26
+ 4. **Stress-test the boundaries.** Where does the artifact's argument or design stop applying? Does it acknowledge that boundary, or does it overgeneralize?
27
+ 5. **Stress-test the audience fit.** Will the intended reader understand it? Is the prerequisite knowledge stated? Are the unstated assumptions reasonable for that audience?
28
+
29
+ ## What to look for
30
+
31
+ - **Internal contradiction.** Two statements that cannot both be true. The artifact must reconcile them or pick one.
32
+ - **Unsupported claims.** Any assertion the artifact relies on but does not justify. The author may have a reason — say so and ask, do not assume incompetence.
33
+ - **Hidden assumptions.** Things the argument quietly requires to be true but does not state. These are the most common failure mode in general writing.
34
+ - **Missing alternatives.** If the artifact recommends X, did it explain why not Y? A serious proposal acknowledges the alternatives it rejected.
35
+ - **Scope drift.** The artifact promises to cover A but spends half its bytes on B. Either the scope is wrong or the title is wrong.
36
+ - **Verifiability.** If the artifact claims success criteria, are they measurable? "Better performance" with no metric is unverifiable.
37
+ - **Logical structure.** Premises → reasoning → conclusion. Where the chain breaks, point at the break.
38
+
39
+ ## What NOT to find
40
+
41
+ - **Stylistic preferences.** Sentence rhythm, word choice variation, paragraph length. Skip unless they actively impede understanding.
42
+ - **Re-summarizing the artifact as a finding.** "This document discusses X" is not a review.
43
+ - **Generic feedback.** "Could be clearer" without pointing at a specific passage is noise.
44
+ - **Disagreements that are taste, not error.** If the author chose path A and you would have chosen B, that is not a finding unless A is actually worse for a stated reason.
45
+
46
+ ## Severity hints
47
+
48
+ - **blocker** — A logical break, a fatal contradiction, a load-bearing claim that is verifiably false, an audience-fit problem so severe the intended reader cannot use the artifact.
49
+ - **concern** — An unsupported claim that needs justification, a missing alternative that weakens the recommendation, a scope ambiguity that will mislead readers.
50
+ - **nit** — A small clarity issue, a passage that could be tightened, a minor inconsistency.
51
+ - **praise** — A non-obvious insight, a tricky trade-off well-handled, a passage that earns the reader's trust. Rare.
52
+
53
+ ## Verdict mapping
54
+
55
+ - **approve** — No blockers. The artifact stands on its own.
56
+ - **request-changes** — At least one blocker.
57
+ - **comment** — Useful observations without a clean accept/reject. Common for early drafts, exploratory documents, or partial reviews.
58
+
59
+ ## Final output
60
+
61
+ Return findings inside the reviewer's neutral \`<review>\` block. Do NOT invent your own output format.
62
+ `
63
+
64
+ export const GENERAL_REVIEW_SKILL: LoadableSkill = {
65
+ name: GENERAL_REVIEW_SKILL_NAME,
66
+ description: GENERAL_REVIEW_SKILL_DESCRIPTION,
67
+ content: GENERAL_REVIEW_SKILL_CONTENT,
68
+ }
@@ -6,7 +6,7 @@ import type {
6
6
  } from 'agent-messenger/discordbot'
7
7
 
8
8
  import type { ChannelAdapterConfig } from '@/channels/schema'
9
- import type { InboundMessage } from '@/channels/types'
9
+ import type { InboundAttachment, InboundMessage } from '@/channels/types'
10
10
 
11
11
  export type InboundDropReason =
12
12
  | 'self_author' // event.author.id === botUserId; we never route our own messages back to ourselves
@@ -35,7 +35,7 @@ export function classifyInbound(
35
35
  if (botUserId !== null && event.author.id === botUserId) {
36
36
  return { kind: 'drop', reason: 'self_author' }
37
37
  }
38
- const text = inboundText(event)
38
+ const { text, attachments } = splitInbound(event)
39
39
  if (text === '') return { kind: 'drop', reason: 'empty_content' }
40
40
 
41
41
  const isDm = event.guild_id === undefined
@@ -80,6 +80,7 @@ export function classifyInbound(
80
80
  chat: event.channel_id,
81
81
  thread: null,
82
82
  text,
83
+ ...(attachments.length > 0 ? { attachments } : {}),
83
84
  externalMessageId: event.id,
84
85
  authorId: event.author.id,
85
86
  // Discord's post-2023 username system allows pure-numeric handles (e.g.
@@ -107,38 +108,45 @@ function isReplyToBot(event: DiscordGatewayMessageCreateEvent, botUserId: string
107
108
  return (event.mentions ?? []).some((m) => m.id === botUserId)
108
109
  }
109
110
 
110
- function inboundText(event: DiscordGatewayMessageCreateEvent): string {
111
- const mediaSummary = summarizeDiscordMedia(event)
112
- if (mediaSummary.length === 0) return event.content
113
- const summary = `[Discord message with ${mediaSummary.join('; ')}]`
114
- return event.content === '' ? summary : `${event.content}\n${summary}`
111
+ type SplitInbound = { text: string; attachments: InboundAttachment[] }
112
+
113
+ function splitInbound(event: DiscordGatewayMessageCreateEvent): SplitInbound {
114
+ const attachments = describeDiscordMedia(event)
115
+ if (attachments.length === 0) return { text: event.content, attachments: [] }
116
+ const summary = attachments.map(renderPlaceholder).join('\n')
117
+ const text = event.content === '' ? summary : `${event.content}\n${summary}`
118
+ return { text, attachments }
115
119
  }
116
120
 
117
- function summarizeDiscordMedia(event: DiscordGatewayMessageCreateEvent): string[] {
121
+ function describeDiscordMedia(event: DiscordGatewayMessageCreateEvent): InboundAttachment[] {
118
122
  return [
119
- ...(event.attachments ?? []).map(summarizeAttachment),
120
- ...(event.embeds ?? []).map(summarizeEmbed),
121
- ...(event.sticker_items ?? []).map(summarizeSticker),
122
- ]
123
+ ...(event.attachments ?? []).map(describeAttachment),
124
+ ...(event.embeds ?? []).map(describeEmbed),
125
+ ...(event.sticker_items ?? []).map(describeSticker),
126
+ ].map((attachment, index) => ({ ...attachment, id: index + 1 }))
123
127
  }
124
128
 
125
- function summarizeAttachment(attachment: DiscordFile): string {
126
- return compactJoin(' ', [
127
- `attachment: ${attachment.filename}`,
128
- attachment.content_type === undefined ? undefined : `(${attachment.content_type})`,
129
- attachment.url,
130
- ])
129
+ function describeAttachment(attachment: DiscordFile): Omit<InboundAttachment, 'id'> {
130
+ return {
131
+ kind: 'file',
132
+ ref: attachment.url,
133
+ filename: attachment.filename,
134
+ ...(attachment.content_type !== undefined ? { mimetype: attachment.content_type } : {}),
135
+ }
131
136
  }
132
137
 
133
- function summarizeEmbed(embed: DiscordGatewayEmbed): string {
138
+ function describeEmbed(embed: DiscordGatewayEmbed): Omit<InboundAttachment, 'id'> {
134
139
  const label = embed.title ?? embed.description ?? embed.url ?? embed.type ?? 'embed'
135
- return compactJoin(' ', ['embed:', label, embed.url !== undefined && embed.url !== label ? embed.url : undefined])
140
+ return { kind: 'embed', ref: embed.url ?? '', filename: label }
136
141
  }
137
142
 
138
- function summarizeSticker(sticker: DiscordGatewayStickerItem): string {
139
- return `sticker: ${sticker.name}`
143
+ function describeSticker(sticker: DiscordGatewayStickerItem): Omit<InboundAttachment, 'id'> {
144
+ return { kind: 'sticker', ref: '', filename: sticker.name }
140
145
  }
141
146
 
142
- function compactJoin(separator: string, parts: Array<string | undefined>): string {
143
- return parts.filter((part) => part !== undefined && part !== '').join(separator)
147
+ function renderPlaceholder(attachment: InboundAttachment): string {
148
+ const parts: string[] = [`Discord attachment #${attachment.id}: ${attachment.kind}`]
149
+ if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
150
+ if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
151
+ return `[${parts.join(' ')}]`
144
152
  }
@@ -44,11 +44,17 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
44
44
  if (!isGithubEventAllowed(options.allowlist(), event, action)) return ok()
45
45
 
46
46
  const selfId = options.selfId()
47
+ const selfLogin = options.selfLogin()
47
48
  const author = readAuthor(payload)
48
- if (selfId !== null && author !== null && String(author.id) === selfId) return ok()
49
+ if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
50
+ options.logger.info(
51
+ `[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
52
+ )
53
+ return ok()
54
+ }
49
55
 
50
56
  const teamIsBotMember = await resolveTeamMembership(event, payload, options)
51
- const classified = classifyGithubInbound(event, payload, options.selfLogin(), {
57
+ const classified = classifyGithubInbound(event, payload, selfLogin, {
52
58
  teamIsBotMember,
53
59
  })
54
60
  if (classified === null) return ok()
@@ -366,6 +372,17 @@ function readAuthor(payload: Record<string, unknown>): GithubUser | null {
366
372
  return null
367
373
  }
368
374
 
375
+ // Matches by id OR login. Issue #452 captured a self-responding loop where
376
+ // the id-only guard didn't fire and the bot replied to its own comments ~8
377
+ // times in a row. Login is the second line of defense and aligns with the
378
+ // slack/discord/telegram/kakaotalk adapters, which all drop self-authored
379
+ // events at the classifier layer.
380
+ function isSelfAuthor(author: GithubUser, selfId: string | null, selfLogin: string | null): boolean {
381
+ if (selfId !== null && String(author.id) === selfId) return true
382
+ if (selfLogin !== null && author.login === selfLogin) return true
383
+ return false
384
+ }
385
+
369
386
  type GithubUser = { login: string; id: number; type?: string }
370
387
 
371
388
  function readUser(value: unknown): GithubUser | null {
@@ -7,79 +7,84 @@ import {
7
7
  type KakaoTalkPushMessageEvent,
8
8
  } from 'agent-messenger/kakaotalk'
9
9
 
10
- // agent-messenger 2.15.0 added two inbound surfaces that 2.14.1 hid from
11
- // the adapter: `KakaoTalkPushMessageEvent.attachment` (photos, files, etc.)
12
- // and a separate `emoticon` listener event for stickers. The SDK leaves
13
- // the `attachment` Record opaque on purpose ("treat it as opaque and
14
- // narrow per `type`", docs/sdk/kakaotalk.mdx). For photos (type=2) the
15
- // keys are documented (`k`, `w`, `h`, `mt`, `url`). For everything else
16
- // (video, audio, voice, file, contact, multi-photo, ...) the SDK has
17
- // neither test fixtures nor field documentation, so we fall back to a
18
- // generic JSON-keys preview that still gives the agent something useful
19
- // to reason about.
10
+ import type { InboundAttachment } from '@/channels/types'
11
+
12
+ // Splits an inbound KakaoTalk event into (text, attachments[]). Text is
13
+ // what the agent sees in its prompt; attachments[] carries the fetchable
14
+ // `ref` (URL or file id) plus safe-to-print metadata that the router uses
15
+ // to resolve `channel_fetch_attachment` / `look_at` calls by `attachment_id`.
20
16
  //
21
- // The synthesized text follows the same `[KakaoTalk message with ...]`
22
- // convention used by Slack/Discord/Telegram inbound classifiers, so the
23
- // agent sees a consistent placeholder shape across platforms.
24
-
25
- // Non-text inputs that the adapter accepts. We use a thin shared shape
26
- // rather than the SDK's union so the same formatter can serve both push
27
- // events (no `attachment` on emoticon events emoticon fields live on
28
- // the event itself) and history messages.
17
+ // The placeholder rendered into text is intentionally REF-FREE. Past
18
+ // regressions where the agent pasted a malformed ref (a bare KakaoCDN
19
+ // `k` key, an expired pre-signed URL, the wrong dialect across adapters)
20
+ // all stemmed from welding the ref into the prompt text. Keeping the ref
21
+ // out of the LLM's view means there is exactly ONE way to fetch an
22
+ // attachment by its in-turn id and the router validates that id
23
+ // against the actual inbounds, blocking hallucinated attachments by
24
+ // construction.
25
+
29
26
  type InboundLike = {
30
27
  message: string
31
28
  message_type: number
32
29
  attachment: Record<string, unknown> | null
33
30
  }
34
31
 
35
- export function formatInboundText(event: InboundLike): string {
32
+ export type SplitInbound = {
33
+ text: string
34
+ attachments: InboundAttachment[]
35
+ }
36
+
37
+ export function splitInbound(event: InboundLike, startId = 1): SplitInbound {
36
38
  const rawText = event.message ?? ''
37
- const summary = summarizeAttachment(event)
38
- if (summary === null) return rawText
39
- const wrapped = `[KakaoTalk message with ${summary}]`
40
- return rawText === '' ? wrapped : `${rawText}\n${wrapped}`
41
- }
42
-
43
- // Synthesizes the displayed text for a sticker / emoticon event. Stickers
44
- // have no `message` field on the push event — the SDK extracts `pack_id`
45
- // and `sticker_path` from the LOCO attachment for us, so we render those
46
- // directly into the placeholder. Matches Discord's `sticker: name` shape
47
- // (src/channels/adapters/discord-bot-classify.ts) but adds Kakao-specific
48
- // fields the agent can use to disambiguate which sticker the user sent.
49
- export function formatEmoticonText(
39
+ const attachment = describeAttachment(event)
40
+ if (attachment === null) return { text: rawText, attachments: [] }
41
+
42
+ const id = startId
43
+ const placeholder = renderPlaceholder(id, attachment)
44
+ const text = rawText === '' ? placeholder : `${rawText}\n${placeholder}`
45
+ return { text, attachments: [{ ...attachment, id }] }
46
+ }
47
+
48
+ export function splitEmoticonInbound(
50
49
  event: Pick<KakaoTalkPushEmoticonEvent, 'emoticon_kind' | 'pack_id' | 'sticker_path'>,
51
- ): string {
52
- return `[KakaoTalk message with ${summarizeEmoticon(event)}]`
50
+ startId = 1,
51
+ ): SplitInbound {
52
+ const id = startId
53
+ const attachment = describeEmoticon(event, id)
54
+ const placeholder = renderPlaceholder(id, attachment)
55
+ return { text: placeholder, attachments: [attachment] }
53
56
  }
54
57
 
55
- function summarizeAttachment(event: InboundLike): string | null {
56
- // Narrow to message types we know how to render. Anything else (system
57
- // events, deleted messages, future LOCO control packets that the SDK
58
- // surfaces as MSG with empty text) intentionally falls through to a
59
- // null summary so classifyInbound's empty_text drop fires and the
60
- // agent isn't woken up by phantom `[KakaoTalk message with type=N]`
61
- // placeholders for noise.
58
+ export function splitHistoryInbound(message: KakaoMessage, startId = 1): SplitInbound {
59
+ return splitInbound(
60
+ {
61
+ message: message.message,
62
+ message_type: message.type,
63
+ attachment: message.attachment,
64
+ },
65
+ startId,
66
+ )
67
+ }
68
+
69
+ type DescribedAttachment = Omit<InboundAttachment, 'id'>
70
+
71
+ function describeAttachment(event: InboundLike): DescribedAttachment | null {
62
72
  switch (event.message_type) {
63
73
  case KAKAO_MESSAGE_TYPE.TEXT:
64
74
  return null
65
75
  case KAKAO_MESSAGE_TYPE.PHOTO:
66
- return summarizePhoto(event.attachment)
76
+ return describePhoto(event.attachment)
67
77
  case KAKAO_MESSAGE_TYPE.VIDEO:
68
- return summarizeGeneric('video', event.attachment)
78
+ return describeGeneric('video', event.attachment)
69
79
  case KAKAO_MESSAGE_TYPE.AUDIO:
70
- return summarizeGeneric('audio', event.attachment)
80
+ return describeGeneric('audio', event.attachment)
71
81
  case KAKAO_MESSAGE_TYPE.FILE:
72
- return summarizeFile(event.attachment)
82
+ return describeFile(event.attachment)
73
83
  case KAKAO_MESSAGE_TYPE.MULTIPHOTO:
74
- return summarizeGeneric('multiphoto', event.attachment)
84
+ return describeGeneric('multiphoto', event.attachment)
75
85
  default:
76
- // Emoticon types route through the dedicated emoticon event before
77
- // they reach this function, but a history fetch can still return
78
- // them as plain KakaoMessage rows. Render them with the same
79
- // sticker shape so chronology is consistent across live and
80
- // history paths.
81
86
  if (isEmoticonType(event.message_type)) {
82
- return summarizeHistoricalEmoticon(event.message_type, event.attachment)
87
+ return describeHistoricalEmoticon(event.message_type, event.attachment)
83
88
  }
84
89
  return null
85
90
  }
@@ -89,86 +94,105 @@ function isEmoticonType(type: number): boolean {
89
94
  return type in KAKAO_EMOTICON_KIND_BY_TYPE
90
95
  }
91
96
 
92
- function summarizePhoto(attachment: Record<string, unknown> | null): string {
93
- if (attachment === null) return 'photo'
94
- const parts = ['photo']
97
+ function describePhoto(attachment: Record<string, unknown> | null): DescribedAttachment {
98
+ const base: DescribedAttachment = { kind: 'photo', ref: '' }
99
+ if (attachment === null) return base
95
100
  const width = numericField(attachment, 'w')
96
101
  const height = numericField(attachment, 'h')
97
- if (width !== null && height !== null) parts.push(`${width}x${height}`)
98
102
  const mime = stringField(attachment, 'mt')
99
- if (mime !== null) parts.push(`(${mime})`)
100
- // Prefer the public URL over the CDN key the URL is dereferenceable,
101
- // the key is an internal CDN path. Either is acceptable as a `ref` if
102
- // we ever wire fetchAttachment for photos.
103
- const url = stringField(attachment, 'url') ?? stringField(attachment, 'k')
104
- if (url !== null) parts.push(url)
105
- return parts.join(' ')
106
- }
107
-
108
- function summarizeFile(attachment: Record<string, unknown> | null): string {
109
- if (attachment === null) return 'file'
110
- const parts = ['file']
111
- // File attachments are not documented by the SDK; these field names are
112
- // best-effort common keys (`name`, `size`, `mt`, `url`) used by similar
113
- // protocols. If a key is absent we just omit it rather than fabricating
114
- // a value.
103
+ // Prefer the public pre-signed URL; fall back to the CDN key only as a
104
+ // diagnostic hint in metadata, NEVER as `ref`. The bare key is not a
105
+ // valid HTTPS URL and historically caused agents to call
106
+ // channel_fetch_attachment with a malformed string. Without a real URL,
107
+ // the agent will still see the placeholder ("a photo arrived") and can
108
+ // ask the user to re-share if needed.
109
+ const url = stringField(attachment, 'url')
110
+ const out: DescribedAttachment = {
111
+ ...base,
112
+ ref: url ?? '',
113
+ ...(mime !== null ? { mimetype: mime } : {}),
114
+ ...(width !== null ? { width } : {}),
115
+ ...(height !== null ? { height } : {}),
116
+ }
117
+ return out
118
+ }
119
+
120
+ function describeFile(attachment: Record<string, unknown> | null): DescribedAttachment {
121
+ const base: DescribedAttachment = { kind: 'file', ref: '' }
122
+ if (attachment === null) return base
115
123
  const name = stringField(attachment, 'name')
116
- if (name !== null) parts.push(name)
117
124
  const mime = stringField(attachment, 'mt')
118
- if (mime !== null) parts.push(`(${mime})`)
119
125
  const size = numericField(attachment, 'size') ?? numericField(attachment, 's')
120
- if (size !== null) parts.push(`size=${size}`)
121
- const url = stringField(attachment, 'url')
122
- if (url !== null) parts.push(url)
123
- return parts.length === 1 ? `file ${attachmentKeysSummary(attachment)}` : parts.join(' ')
124
- }
125
-
126
- function summarizeGeneric(label: string, attachment: Record<string, unknown> | null): string {
127
- if (attachment === null) return label
128
- // Prefer a dereferenceable URL over a keys-only preview: the agent uses
129
- // the URL as the `ref` for channel_fetch_attachment, so making it visible
130
- // in the placeholder is what turns video/audio/multiphoto from
131
- // "described" into "fetchable". When the SDK hands us an opaque payload
132
- // with no `url` (the documented case for these types), fall back to
133
- // listing the available keys so we never lie about what arrived.
134
126
  const url = stringField(attachment, 'url')
135
- if (url !== null) return `${label} (${attachmentKeysSummary(attachment)}) ${url}`
136
- return `${label} ${attachmentKeysSummary(attachment)}`
127
+ return {
128
+ ...base,
129
+ ref: url ?? '',
130
+ ...(name !== null ? { filename: name } : {}),
131
+ ...(mime !== null ? { mimetype: mime } : {}),
132
+ ...(size !== null ? { sizeBytes: size } : {}),
133
+ }
137
134
  }
138
135
 
139
- // Last-resort renderer: list the attachment's keys so the agent at least
140
- // knows what shape the payload had. We deliberately do NOT dump values —
141
- // some attachment payloads contain long base64 strings or large URLs that
142
- // would blow the agent's context window if pasted whole.
143
- function attachmentKeysSummary(attachment: Record<string, unknown>): string {
144
- const keys = Object.keys(attachment).sort()
145
- if (keys.length === 0) return '(empty)'
146
- return `keys=[${keys.join(',')}]`
136
+ function describeGeneric(
137
+ kind: 'video' | 'audio' | 'multiphoto',
138
+ attachment: Record<string, unknown> | null,
139
+ ): DescribedAttachment {
140
+ const base: DescribedAttachment = { kind, ref: '' }
141
+ if (attachment === null) return base
142
+ const url = stringField(attachment, 'url')
143
+ const mime = stringField(attachment, 'mt')
144
+ return {
145
+ ...base,
146
+ ref: url ?? '',
147
+ ...(mime !== null ? { mimetype: mime } : {}),
148
+ }
147
149
  }
148
150
 
149
- function summarizeEmoticon(
151
+ function describeEmoticon(
150
152
  event: Pick<KakaoTalkPushEmoticonEvent, 'emoticon_kind' | 'pack_id' | 'sticker_path'>,
151
- ): string {
152
- const parts = [`sticker (${event.emoticon_kind})`]
153
- if (event.pack_id !== null) parts.push(`pack=${event.pack_id}`)
154
- if (event.sticker_path !== null) parts.push(`path=${event.sticker_path}`)
155
- return parts.join(' ')
153
+ id: number,
154
+ ): InboundAttachment {
155
+ // Stickers have no fetchable ref in the LOCO push payload; they are
156
+ // rendered client-side from `pack_id` + `sticker_path` against a
157
+ // packaged sprite set. Surface those as filename so the placeholder is
158
+ // informative, and leave `ref` empty — channel_fetch_attachment will
159
+ // refuse the lookup and tell the agent the sticker is unfetchable.
160
+ const filename =
161
+ event.sticker_path !== null && event.sticker_path !== '' ? event.sticker_path : `sticker-${event.emoticon_kind}`
162
+ return {
163
+ id,
164
+ kind: 'sticker',
165
+ ref: '',
166
+ filename,
167
+ }
156
168
  }
157
169
 
158
- function summarizeHistoricalEmoticon(messageType: number, attachment: Record<string, unknown> | null): string {
170
+ function describeHistoricalEmoticon(
171
+ messageType: number,
172
+ attachment: Record<string, unknown> | null,
173
+ ): DescribedAttachment {
159
174
  const kind: KakaoEmoticonKind | undefined =
160
175
  KAKAO_EMOTICON_KIND_BY_TYPE[messageType as keyof typeof KAKAO_EMOTICON_KIND_BY_TYPE]
161
- const parts = [`sticker (${kind ?? `type=${messageType}`})`]
176
+ let filename: string | null = null
162
177
  if (attachment !== null) {
163
- const path = stringField(attachment, 'path') ?? stringField(attachment, 'emoticonItemPath')
164
- if (path !== null) {
165
- const dotIndex = path.indexOf('.')
166
- const head = dotIndex > 0 ? path.slice(0, dotIndex) : null
167
- if (head !== null && /^\d+$/.test(head)) parts.push(`pack=${head}`)
168
- parts.push(`path=${path}`)
169
- }
178
+ filename = stringField(attachment, 'path') ?? stringField(attachment, 'emoticonItemPath')
179
+ }
180
+ return {
181
+ kind: 'sticker',
182
+ ref: '',
183
+ ...(filename !== null ? { filename } : { filename: kind ?? `sticker-${messageType}` }),
170
184
  }
171
- return parts.join(' ')
185
+ }
186
+
187
+ function renderPlaceholder(id: number, attachment: DescribedAttachment | InboundAttachment): string {
188
+ const parts: string[] = [`KakaoTalk attachment #${id}: ${attachment.kind}`]
189
+ if (attachment.width !== undefined && attachment.height !== undefined) {
190
+ parts.push(`${attachment.width}x${attachment.height}`)
191
+ }
192
+ if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
193
+ if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
194
+ if (attachment.sizeBytes !== undefined) parts.push(`size=${attachment.sizeBytes}`)
195
+ return `[${parts.join(' ')}]`
172
196
  }
173
197
 
174
198
  function stringField(record: Record<string, unknown>, key: string): string | null {
@@ -181,34 +205,17 @@ function numericField(record: Record<string, unknown>, key: string): number | nu
181
205
  return typeof value === 'number' && Number.isFinite(value) ? value : null
182
206
  }
183
207
 
184
- // Wraps a KakaoTalk emoticon push event into the MSG-shaped payload that
185
- // `classifyInbound` expects. We synthesize `message` from the sticker
186
- // metadata so the classifier's empty-text drop doesn't fire on stickers,
187
- // and we carry the original message_type through so a later code path
188
- // can still distinguish stickers from text if needed.
189
208
  export function emoticonEventToMessageEvent(event: KakaoTalkPushEmoticonEvent): KakaoTalkPushMessageEvent {
209
+ const { text } = splitEmoticonInbound(event)
190
210
  return {
191
211
  type: 'MSG',
192
212
  chat_id: event.chat_id,
193
213
  log_id: event.log_id,
194
214
  author_id: event.author_id,
195
215
  author_name: event.author_name,
196
- message: formatEmoticonText(event),
216
+ message: text,
197
217
  message_type: event.message_type,
198
218
  attachment: null,
199
219
  sent_at: event.sent_at,
200
220
  }
201
221
  }
202
-
203
- // Helper used by the history callback to convert a KakaoMessage (which
204
- // shares the same `attachment` shape as the push event) into displayable
205
- // text. Kept separate from `formatInboundText` so the live and history
206
- // paths can evolve independently — e.g. history may eventually surface
207
- // thumbnails or extra fields the push event doesn't carry.
208
- export function formatHistoryText(message: KakaoMessage): string {
209
- return formatInboundText({
210
- message: message.message,
211
- message_type: message.type,
212
- attachment: message.attachment,
213
- })
214
- }
@@ -2,7 +2,7 @@ import type { KakaoTalkPushMessageEvent } from 'agent-messenger/kakaotalk'
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
  export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect' | 'bot_message'
8
8
 
@@ -28,6 +28,10 @@ export type KakaoInboundContext = {
28
28
  selfUserId: string | null
29
29
  lookupChat: KakaoChatLookup
30
30
  selfAliases?: readonly string[]
31
+ // The adapter splits attachment refs out of prompt-visible text before
32
+ // classification. Keeping them on context makes classifyInbound's payload
33
+ // construction the single place that stamps InboundMessage fields.
34
+ attachments?: readonly InboundAttachment[]
31
35
  }
32
36
 
33
37
  export function classifyInbound(
@@ -70,6 +74,9 @@ export function classifyInbound(
70
74
  chat: event.chat_id,
71
75
  thread: null,
72
76
  text,
77
+ ...(context.attachments !== undefined && context.attachments.length > 0
78
+ ? { attachments: context.attachments }
79
+ : {}),
73
80
  externalMessageId: event.log_id,
74
81
  authorId: String(event.author_id),
75
82
  authorName: event.author_name ?? String(event.author_id),