typeclaw 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -46,7 +46,7 @@
46
46
  "@mariozechner/pi-coding-agent": "^0.67.3",
47
47
  "@mariozechner/pi-tui": "^0.67.3",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "agent-messenger": "2.18.0",
49
+ "agent-messenger": "2.19.0",
50
50
  "cheerio": "^1.2.0",
51
51
  "citty": "^0.2.2",
52
52
  "cron-parser": "^5.5.0",
@@ -151,19 +151,20 @@ TypeClaw runtime version: ${version}.`
151
151
  // would already be re-billed on each turn's user message — so this is
152
152
  // cache-free relative to the previous "## Now" placement.
153
153
  //
154
- // The block emits both English and Korean weekday names alongside the ISO
155
- // timestamp because models replying in a non-English language frequently
156
- // compute weekday-from-ISO incorrectly; pre-computing the weekday in both
157
- // candidate reply languages removes that arithmetic step entirely. The
158
- // framing is a single `<current-time>` XML tag for parity with other
159
- // runtime-injected per-turn blocks the agent already sees
160
- // (`<system-reminder>` etc.), so the model reads it as a structured anchor
161
- // rather than as content authored by a human in the chat.
154
+ // The block emits the English weekday name alongside the ISO timestamp
155
+ // because models frequently compute weekday-from-ISO incorrectly;
156
+ // pre-computing it removes that arithmetic step entirely. English only:
157
+ // TypeClaw's users are global, so the anchor uses one canonical language
158
+ // and leaves reply language to each agent's SOUL.md. The framing is a
159
+ // single `<current-time>` XML tag for parity with other runtime-injected
160
+ // per-turn blocks the agent already sees (`<system-reminder>` etc.), so
161
+ // the model reads it as a structured anchor rather than as content
162
+ // authored by a human in the chat.
162
163
  export function renderTurnTimeAnchor(now: Date = new Date()): string {
163
164
  const iso = formatLocalDateTime(now)
164
165
  const zone = resolveLocalTimezoneName()
165
166
  const weekday = formatLocalWeekday(now)
166
- return `<current-time>${iso} (${zone}, ${weekday.en} / ${weekday.ko})</current-time>`
167
+ return `<current-time>${iso} (${zone}, ${weekday})</current-time>`
167
168
  }
168
169
 
169
170
  // Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
@@ -10,7 +10,7 @@ import {
10
10
  import type { AdapterId } from '@/channels/schema'
11
11
 
12
12
  import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
13
- import { fenceRuntimeNotice } from './runtime-notice'
13
+ import { fenceRuntimeNotice, fenceToolResult } from './runtime-notice'
14
14
 
15
15
  export type ChannelReplyOrigin = {
16
16
  adapter: AdapterId
@@ -138,34 +138,37 @@ export function createChannelReplyTool({
138
138
  // Without this echo, a model that splits a multi-part reply has no
139
139
  // way to tell "did I already send part 1?" from "I haven't started
140
140
  // yet", and routinely re-sends near-duplicates within the same turn
141
- // (observed in production: two consecutive identical
142
- // greeting messages to one prompt).
141
+ // (observed in production: two consecutive identical greeting messages
142
+ // to one prompt).
143
143
  //
144
- // We deliberately do NOT cap sends-per-turn here. A complex user
145
- // request legitimately needs split replies, and a hard cap would
146
- // mutilate that. The fix is to give the model honest feedback —
147
- // show it what it sent, let it decide whether to continue.
148
- // Truncate past 500 chars so a long reply doesn't double the prompt
149
- // size on every subsequent iteration; the prefix is enough to detect
150
- // duplication, and the full text is recoverable from the session
151
- // JSONL if needed.
152
- const echo = renderOutboundEcho(text, attachments)
153
- const baseText = result.ok
154
- ? `posted to ${origin.adapter}:${origin.workspace}/${origin.chat}: ${echo}`
155
- : `channel_reply denied: ${result.error}`
156
- const hint = result.ok
157
- ? consecutiveSendHint(
158
- router.getConsecutiveSendCount({
159
- adapter: origin.adapter,
160
- workspace: origin.workspace,
161
- chat: origin.chat,
162
- thread: origin.thread,
163
- }),
164
- )
165
- : ''
166
- const body = hint ? `${baseText}${hint}` : baseText
144
+ // The echo is the model's OWN words, which is uniquely seductive to
145
+ // "reply" to, so on the success path we wrap the whole result in the
146
+ // strong SYSTEM MESSAGE fence (`fenceToolResult`) rather than the weak
147
+ // `[system: tool result...]` prefix the prefix did not stop Kimi from
148
+ // answering its own echo and looping (PR #481). Denials carry no echoed
149
+ // prose (just machine error text), so they keep the lighter prefix.
150
+ if (result.ok) {
151
+ const echo = renderOutboundEcho(text, attachments)
152
+ const receipt = `posted to ${origin.adapter}:${origin.workspace}/${origin.chat}: ${echo}`
153
+ const hint = consecutiveSendHint(
154
+ router.getConsecutiveSendCount({
155
+ adapter: origin.adapter,
156
+ workspace: origin.workspace,
157
+ chat: origin.chat,
158
+ thread: origin.thread,
159
+ }),
160
+ )
161
+ // Keep fenceToolResult here — do NOT "unify" the success branch back to
162
+ // TOOL_RESULT_PREFIX to match the denial branch below. The prefix is
163
+ // intentionally weaker and is safe ONLY because denials carry no echoed
164
+ // prose; the success result does, and the weak prefix let Kimi loop.
165
+ return {
166
+ content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hint}` }],
167
+ details,
168
+ }
169
+ }
167
170
  return {
168
- content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${body}` }],
171
+ content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_reply denied: ${result.error}` }],
169
172
  details,
170
173
  }
171
174
  },
@@ -188,6 +191,13 @@ export function renderEcho(text: string): string {
188
191
  return `${JSON.stringify(text.slice(0, ECHO_MAX_CHARS))}... (${text.length} chars total)`
189
192
  }
190
193
 
194
+ // DO NOT remove this echo or replace it with a hash/length-only "receipt" to
195
+ // stop the self-reply loop (PR #481). That trade was tried and rejected: the
196
+ // echo is the model's only view of what it already said (the inbound path
197
+ // drops self-authored messages), so without the FULL text a split reply
198
+ // re-sends near-duplicates — the exact bug 58c62c1 added the echo to fix, and
199
+ // a fingerprint cannot catch paraphrased near-dupes. The loop is solved by
200
+ // FENCING this echo (see fenceToolResult call site below), not by removing it.
191
201
  export function renderOutboundEcho(
192
202
  text: string | undefined,
193
203
  attachments: ReadonlyArray<{ path: string; filename?: string }> | undefined,
@@ -11,7 +11,7 @@ import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
11
11
 
12
12
  import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
13
13
  import { renderOutboundEcho, TOOL_RESULT_PREFIX } from './channel-reply'
14
- import { fenceRuntimeNotice } from './runtime-notice'
14
+ import { fenceRuntimeNotice, fenceToolResult } from './runtime-notice'
15
15
 
16
16
  export type ChannelSendOrigin = {
17
17
  adapter: AdapterId
@@ -154,12 +154,13 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
154
154
  )
155
155
  }
156
156
  const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
157
- const echo = renderOutboundEcho(bodyText, attachments)
158
- const baseText = result.ok
159
- ? `posted to ${params.adapter}:${params.workspace}/${params.chat}: ${echo}`
160
- : `channel_send denied: ${result.error}`
161
- const hints: string[] = []
157
+ // Success wraps the echoed sent text in the strong SYSTEM MESSAGE fence;
158
+ // denials keep the lighter prefix. See channel-reply.ts for the full
159
+ // rationale (PR #481 self-reply loop).
162
160
  if (result.ok) {
161
+ const echo = renderOutboundEcho(bodyText, attachments)
162
+ const receipt = `posted to ${params.adapter}:${params.workspace}/${params.chat}: ${echo}`
163
+ const hints: string[] = []
163
164
  const consecutive = consecutiveSendHint(
164
165
  router.getConsecutiveSendCount({
165
166
  adapter,
@@ -177,10 +178,14 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
177
178
  thread: params.thread,
178
179
  })
179
180
  if (threadMismatch) hints.push(threadMismatch)
181
+
182
+ return {
183
+ content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hints.join('')}` }],
184
+ details,
185
+ }
180
186
  }
181
- const body = hints.length > 0 ? `${baseText}${hints.join('')}` : baseText
182
187
  return {
183
- content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${body}` }],
188
+ content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_send denied: ${result.error}` }],
184
189
  details,
185
190
  }
186
191
  },
@@ -39,3 +39,31 @@ export function fenceRuntimeNotice(body: string): string {
39
39
  '---'
40
40
  )
41
41
  }
42
+
43
+ // Wraps a channel tool result (delivery confirmation + echoed sent text) in the
44
+ // SAME canonical SYSTEM MESSAGE framing as fenceRuntimeNotice — but as the
45
+ // ENTIRE result body, not an appended hint, so there is no unfenced prose for
46
+ // the model to read as conversation.
47
+ //
48
+ // The echoed sent text is load-bearing (the bot has no other view of what it
49
+ // just said — the inbound path drops self-authored messages — so without it a
50
+ // split reply re-sends near-duplicates). But that text is the model's OWN
51
+ // words, which is uniquely seductive to "reply" to: a persona-rich model
52
+ // (Kimi K2 on the GitHub channel, PR #481) read its own delivered prose as a
53
+ // fresh user turn and answered it ("you're welcome!", "aww thanks!") until the
54
+ // per-turn send cap. The weak `[system: tool result...]` prefix did not stop
55
+ // the misread; the full fence — bracketed marker, horizontal-rule fences,
56
+ // explicit "Do not reply" closer — has months of production track record
57
+ // against Kimi (it already wraps the consecutive-send and thread-mismatch
58
+ // hints). Reusing the exact same shape extends that protection to the echo.
59
+ export function fenceToolResult(receipt: string): string {
60
+ return (
61
+ '---\n' +
62
+ '**[SYSTEM MESSAGE — not from a human]**\n\n' +
63
+ receipt +
64
+ '\n\nThe text above is your OWN already-delivered message, echoed back so ' +
65
+ 'you can see what you sent — it is NOT a new message from anyone in the ' +
66
+ 'chat. **Do not acknowledge or reply to it.**\n' +
67
+ '---'
68
+ )
69
+ }
@@ -24,6 +24,7 @@ export const webfetchTool = defineTool({
24
24
  description:
25
25
  'Fetch a single HTTP(S) URL and return the body, optionally compacted by a strategy. ' +
26
26
  'Use this when the user references a specific URL or when websearch surfaced a result you need to read in full. ' +
27
+ 'If `spawn_subagent` is available to you, PREFER delegating to the `scout` subagent by default: spawn it whenever you expect more than one fetch, an "across multiple sources" task, or any search-then-fetch loop. Scout runs the noisy fetching in its own context window and returns a distilled, citation-backed answer, keeping bulky page bodies out of yours. Only call this tool directly for a single known URL whose content you will cite immediately — or whenever you cannot spawn subagents (e.g. you are yourself a subagent), in which case fetch here. ' +
27
28
  'Outbound requests impersonate Chrome 136 at the TLS, HTTP/2, and header layers ' +
28
29
  '(via curl-impersonate), which helps with TLS/header fingerprint gates on sites behind Cloudflare/Akamai. ' +
29
30
  'It does NOT solve JavaScript challenges, behavioural fingerprinting (mouse/scroll/timing), interactive CAPTCHAs, ' +
@@ -20,7 +20,8 @@ export const websearchTool = defineTool({
20
20
  name: 'websearch',
21
21
  label: 'Web Search',
22
22
  description:
23
- 'Search the public web. Returns a ranked list of {title, url, snippet} entries. Use `source: "wikipedia"` for encyclopedic lookups; otherwise default to general web results from DuckDuckGo. Pair this with the `read` tool by visiting URLs you find with `bash` (curl) when you need full page contents.',
23
+ 'Search the public web. Returns a ranked list of {title, url, snippet} entries. Use `source: "wikipedia"` for encyclopedic lookups; otherwise default to general web results from DuckDuckGo. Pair this with the `read` tool by visiting URLs you find with `bash` (curl) when you need full page contents.\n' +
24
+ 'If `spawn_subagent` is available to you, PREFER delegating to the `scout` subagent by default: spawn it whenever the research is non-trivial (more than 1-2 queries, any "across multiple sources" framing, or follow-up fetches of the results). Scout runs `websearch`/`webfetch` in its own context window and returns a distilled, citation-backed answer, so the search churn never pollutes yours. Only call this tool directly for a single query whose top result you will cite immediately — or whenever you cannot spawn subagents (e.g. you are yourself a subagent), in which case run the searches here.',
24
25
  parameters: Type.Object({
25
26
  query: Type.String({ description: 'The search query.' }),
26
27
  limit: Type.Optional(
@@ -393,7 +393,14 @@ export function createOutboundCallback(deps: {
393
393
  }
394
394
 
395
395
  try {
396
- const sent = await client.sendMessage(msg.chat, text, msg.thread ? { thread_id: msg.thread } : undefined)
396
+ const sendOptions: { thread_id?: string; reply_to?: string } = {}
397
+ if (msg.thread) sendOptions.thread_id = msg.thread
398
+ if (msg.replyTo?.externalMessageId) sendOptions.reply_to = msg.replyTo.externalMessageId
399
+ const sent = await client.sendMessage(
400
+ msg.chat,
401
+ text,
402
+ Object.keys(sendOptions).length > 0 ? sendOptions : undefined,
403
+ )
397
404
  logger.info(`[discord-bot] sent id=${sent.id} ${tag}`)
398
405
  return { ok: true }
399
406
  } catch (err) {
@@ -0,0 +1,239 @@
1
+ // KakaoTalk's LOCO protocol renders no rich text — bytes display verbatim, so
2
+ // the agent's Markdown (`**bold**`, `### heading`, fenced ```blocks```) leaks
3
+ // literal `*`/`#`/backtick noise into the chat. This strips the formatting
4
+ // markers and keeps the content. Mirrors telegram-bot-format.ts, but emits
5
+ // plain content instead of re-encoding to MarkdownV2. Links collapse to
6
+ // `label (url)` so the destination survives; list/quote markers stay (they
7
+ // read fine unrendered).
8
+
9
+ export function toKakaoPlainText(input: string): string {
10
+ // Pull fenced code out first so a `*` inside a block is not re-tokenized as
11
+ // italic.
12
+ const out: string[] = []
13
+ let i = 0
14
+ while (i < input.length) {
15
+ if (matchesAt(input, i, '```')) {
16
+ const fenceEnd = findFenceEnd(input, i + 3)
17
+ if (fenceEnd !== -1) {
18
+ out.push(renderFence(input.slice(i + 3, fenceEnd)))
19
+ i = fenceEnd + 3
20
+ continue
21
+ }
22
+ // Unterminated fence — strip the open backticks and render the rest
23
+ // inline so we never infinite-loop and never drop the tail.
24
+ out.push(renderInline(stripLeadingFence(input.slice(i + 3))))
25
+ break
26
+ }
27
+ const nextFence = input.indexOf('```', i)
28
+ const segmentEnd = nextFence === -1 ? input.length : nextFence
29
+ out.push(renderLines(input.slice(i, segmentEnd)))
30
+ i = segmentEnd
31
+ }
32
+ return out.join('')
33
+ }
34
+
35
+ function matchesAt(s: string, idx: number, needle: string): boolean {
36
+ return s.slice(idx, idx + needle.length) === needle
37
+ }
38
+
39
+ function findFenceEnd(s: string, start: number): number {
40
+ return s.indexOf('```', start)
41
+ }
42
+
43
+ function stripLeadingFence(inner: string): string {
44
+ // Drop an optional language hint and the newline after an opening fence.
45
+ const newline = inner.indexOf('\n')
46
+ if (newline === -1) return inner
47
+ const candidate = inner.slice(0, newline).trim()
48
+ if (candidate === '' || /^[A-Za-z0-9_+\-.]+$/.test(candidate)) {
49
+ return inner.slice(newline + 1)
50
+ }
51
+ return inner
52
+ }
53
+
54
+ function renderFence(inner: string): string {
55
+ // Keep the code body verbatim, drop the fences and any language hint.
56
+ let body = inner
57
+ const newline = inner.indexOf('\n')
58
+ if (newline !== -1) {
59
+ const candidate = inner.slice(0, newline).trim()
60
+ if (candidate === '' || /^[A-Za-z0-9_+\-.]+$/.test(candidate)) {
61
+ body = inner.slice(newline + 1)
62
+ }
63
+ }
64
+ if (body.endsWith('\n')) body = body.slice(0, -1)
65
+ return body
66
+ }
67
+
68
+ // Strip per-line block markers (heading hashes, blockquote arrows) before
69
+ // running the inline tokenizer on each line. List markers (`- `, `* `, `1.`)
70
+ // are left intact — they read fine as plain text and signal structure.
71
+ function renderLines(text: string): string {
72
+ const lines = text.split('\n')
73
+ const rendered = lines.map((line) => renderInline(stripBlockMarkers(line)))
74
+ return rendered.join('\n')
75
+ }
76
+
77
+ function stripBlockMarkers(line: string): string {
78
+ // `### heading` → `heading`; `> quote` → `quote`. Only acts on leading
79
+ // markers after optional indentation so mid-line `#`/`>` stay literal.
80
+ const heading = /^(\s*)#{1,6}\s+(.*)$/.exec(line)
81
+ if (heading !== null) return heading[1]! + heading[2]!
82
+ const quote = /^(\s*)>\s?(.*)$/.exec(line)
83
+ if (quote !== null) return quote[1]! + quote[2]!
84
+ return line
85
+ }
86
+
87
+ // Inline tokenizer. Recognizes (in priority order):
88
+ // 1. Inline code: `code` → code
89
+ // 2. Links: [text](url) → text (url)
90
+ // 3. Bold: **text** / __text__ → text
91
+ // 4. Strikethrough: ~~text~~ → text
92
+ // 5. Italic: *text* / _text_ → text
93
+ //
94
+ // Bold is checked before italic so `**` is not eaten as two italic markers.
95
+ // Word-boundary guards keep snake_case identifiers and `a*b` math from being
96
+ // mistaken for emphasis — the same rules the Telegram formatter uses.
97
+ function renderInline(text: string): string {
98
+ const out: string[] = []
99
+ let i = 0
100
+ while (i < text.length) {
101
+ const ch = text[i]!
102
+
103
+ if (ch === '`') {
104
+ const close = text.indexOf('`', i + 1)
105
+ if (close !== -1) {
106
+ out.push(text.slice(i + 1, close))
107
+ i = close + 1
108
+ continue
109
+ }
110
+ }
111
+
112
+ if (ch === '[') {
113
+ const link = parseLink(text, i)
114
+ if (link !== null) {
115
+ const label = renderInline(link.label)
116
+ out.push(link.url === '' ? label : `${label} (${link.url})`)
117
+ i = link.end
118
+ continue
119
+ }
120
+ }
121
+
122
+ if (ch === '*' && text[i + 1] === '*') {
123
+ const close = findClose(text, i + 2, '**')
124
+ if (close !== -1 && close > i + 2) {
125
+ out.push(renderInline(text.slice(i + 2, close)))
126
+ i = close + 2
127
+ continue
128
+ }
129
+ }
130
+ if (ch === '_' && text[i + 1] === '_' && !isWordChar(text[i - 1])) {
131
+ const close = findClose(text, i + 2, '__')
132
+ if (close !== -1 && close > i + 2 && !isWordChar(text[close + 2])) {
133
+ out.push(renderInline(text.slice(i + 2, close)))
134
+ i = close + 2
135
+ continue
136
+ }
137
+ }
138
+
139
+ if (ch === '~' && text[i + 1] === '~') {
140
+ const close = findClose(text, i + 2, '~~')
141
+ if (close !== -1 && close > i + 2) {
142
+ out.push(renderInline(text.slice(i + 2, close)))
143
+ i = close + 2
144
+ continue
145
+ }
146
+ }
147
+
148
+ if (ch === '*' && !isWordChar(text[i - 1])) {
149
+ const close = findInlineClose(text, i + 1, '*')
150
+ if (close !== -1 && !isWordChar(text[close + 1])) {
151
+ const inner = text.slice(i + 1, close)
152
+ if (inner !== '' && !/^\s|\s$/.test(inner)) {
153
+ out.push(renderInline(inner))
154
+ i = close + 1
155
+ continue
156
+ }
157
+ }
158
+ }
159
+ if (ch === '_' && !isWordChar(text[i - 1])) {
160
+ const close = findInlineClose(text, i + 1, '_')
161
+ if (close !== -1 && !isWordChar(text[close + 1])) {
162
+ const inner = text.slice(i + 1, close)
163
+ if (inner !== '' && !/^\s|\s$/.test(inner)) {
164
+ out.push(renderInline(inner))
165
+ i = close + 1
166
+ continue
167
+ }
168
+ }
169
+ }
170
+
171
+ out.push(ch)
172
+ i++
173
+ }
174
+ return out.join('')
175
+ }
176
+
177
+ function findClose(text: string, from: number, marker: string): number {
178
+ let i = from
179
+ while (i <= text.length - marker.length) {
180
+ if (text[i] === '\\') {
181
+ i += 2
182
+ continue
183
+ }
184
+ if (matchesAt(text, i, marker)) return i
185
+ i++
186
+ }
187
+ return -1
188
+ }
189
+
190
+ function findInlineClose(text: string, from: number, marker: string): number {
191
+ let i = from
192
+ while (i < text.length) {
193
+ if (text[i] === '\n') return -1
194
+ if (text[i] === '\\') {
195
+ i += 2
196
+ continue
197
+ }
198
+ if (matchesAt(text, i, marker)) return i
199
+ i++
200
+ }
201
+ return -1
202
+ }
203
+
204
+ function parseLink(text: string, start: number): { label: string; url: string; end: number } | null {
205
+ let i = start + 1
206
+ const labelStart = i
207
+ while (i < text.length) {
208
+ const c = text[i]!
209
+ if (c === '\\') {
210
+ i += 2
211
+ continue
212
+ }
213
+ if (c === ']') break
214
+ if (c === '\n') return null
215
+ i++
216
+ }
217
+ if (text[i] !== ']' || text[i + 1] !== '(') return null
218
+ const label = text.slice(labelStart, i)
219
+ const urlStart = i + 2
220
+ let j = urlStart
221
+ while (j < text.length) {
222
+ const c = text[j]!
223
+ if (c === '\\') {
224
+ j += 2
225
+ continue
226
+ }
227
+ if (c === ')') break
228
+ if (c === '(') return null
229
+ if (c === '\n') return null
230
+ j++
231
+ }
232
+ if (text[j] !== ')') return null
233
+ return { label, url: text.slice(urlStart, j), end: j + 1 }
234
+ }
235
+
236
+ function isWordChar(ch: string | undefined): boolean {
237
+ if (ch === undefined) return false
238
+ return /[A-Za-z0-9_]/.test(ch)
239
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type KakaoMember,
9
9
  type KakaoMessage,
10
10
  type KakaoProfile,
11
+ type KakaoReplyTarget,
11
12
  type KakaoSendResult,
12
13
  type KakaoTalkListenerEventMap,
13
14
  type KakaoTalkPushEmoticonEvent,
@@ -15,7 +16,7 @@ import {
15
16
  } from 'agent-messenger/kakaotalk'
16
17
  import type { KakaoAccountCredentials, KakaoConfig, PendingLoginState } from 'agent-messenger/kakaotalk'
17
18
 
18
- import type { ChannelRouter } from '@/channels/router'
19
+ import { prependQuoteAnchor, type ChannelRouter } from '@/channels/router'
19
20
  import type { ChannelAdapterConfig } from '@/channels/schema'
20
21
  import type {
21
22
  ChannelHistoryMessage,
@@ -39,6 +40,7 @@ import { createKakaoAuthorResolver, type KakaoAuthorResolver } from './kakaotalk
39
40
  import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaotalk-channel-resolver'
40
41
  import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
41
42
  import { createFetchAttachmentCallback } from './kakaotalk-fetch-attachment'
43
+ import { toKakaoPlainText } from './kakaotalk-format'
42
44
 
43
45
  // Structural duck-type of the upstream KakaoTalkClient class. The upstream
44
46
  // type is a class with private fields, and TypeScript treats those
@@ -53,7 +55,7 @@ export interface KakaoTalkClient {
53
55
  ): Promise<this>
54
56
  getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]>
55
57
  getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]>
56
- sendMessage(chatId: string, text: string): Promise<KakaoSendResult>
58
+ sendMessage(chatId: string, text: string, options?: { replyTo?: KakaoReplyTarget }): Promise<KakaoSendResult>
57
59
  sendAttachment(
58
60
  chatId: string,
59
61
  data: Uint8Array | Buffer,
@@ -160,6 +162,11 @@ export type KakaotalkAdapter = {
160
162
 
161
163
  export const KAKAO_HISTORY_LIMIT_MAX = 200
162
164
 
165
+ // How far back to scan for a reply target's source message. Matches the upstream
166
+ // CLI's window; an anchored reply targets the message just answered, so the
167
+ // target is almost always near the head of this window.
168
+ const KAKAO_REPLY_LOOKUP_COUNT = 100
169
+
163
170
  function formatLabel(name: string | undefined, id: string, prefix = ''): string {
164
171
  if (name === undefined || name === '' || name === id) return id
165
172
  return `${prefix}${name}(${id})`
@@ -171,7 +178,7 @@ async function readAttachmentBuffer(path: string): Promise<Buffer> {
171
178
  }
172
179
 
173
180
  export function createOutboundCallback(deps: {
174
- client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment'>
181
+ client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment' | 'getMessages'>
175
182
  logger: KakaotalkAdapterLogger
176
183
  formatChannelTag: (workspace: string, chat: string) => Promise<string>
177
184
  readFile?: (path: string) => Promise<Buffer>
@@ -182,7 +189,7 @@ export function createOutboundCallback(deps: {
182
189
  if (msg.adapter !== 'kakaotalk') {
183
190
  return { ok: false, error: `unknown adapter: ${msg.adapter}` }
184
191
  }
185
- const text = msg.text ?? ''
192
+ const text = toKakaoPlainText(msg.text ?? '')
186
193
  const attachments = msg.attachments ?? []
187
194
  if (text === '' && attachments.length === 0) {
188
195
  return { ok: false, error: 'message has neither text nor attachments' }
@@ -221,8 +228,26 @@ export function createOutboundCallback(deps: {
221
228
  }
222
229
 
223
230
  if (text !== '') {
231
+ // KakaoTalk's native reply payload is built from the *source* message
232
+ // (author, original text, type), which the SDK does not derive from a
233
+ // bare log_id — we resolve it from recent history. If that lookup can't
234
+ // find the target (scrolled past the window, or the fetch failed), we
235
+ // degrade to the same blockquote anchor the router uses for quote-mode
236
+ // adapters, so the reply still visibly references the right message.
237
+ let outboundText = text
238
+ let replyTarget: KakaoReplyTarget | undefined
239
+ if (msg.replyTo !== undefined) {
240
+ replyTarget = await resolveKakaoReplyTarget(client, msg.chat, msg.replyTo.externalMessageId, logger)
241
+ if (replyTarget === undefined && msg.replyTo.source !== undefined) {
242
+ outboundText = prependQuoteAnchor(text, msg.replyTo.source)
243
+ }
244
+ }
224
245
  try {
225
- const result = await client.sendMessage(msg.chat, text)
246
+ const result = await client.sendMessage(
247
+ msg.chat,
248
+ outboundText,
249
+ replyTarget !== undefined ? { replyTo: replyTarget } : undefined,
250
+ )
226
251
  if (!result.success) {
227
252
  logger.error(`[kakaotalk] sendMessage status_code=${result.status_code} ${tag}`)
228
253
  return { ok: false, error: `kakaotalk send failed with status ${result.status_code}` }
@@ -239,6 +264,30 @@ export function createOutboundCallback(deps: {
239
264
  }
240
265
  }
241
266
 
267
+ // KakaoTalk replies need the full source message, not just its log_id. Resolve
268
+ // it from the chat's recent history (matching the upstream CLI's approach).
269
+ // Returns undefined when the target isn't in the fetched window or the fetch
270
+ // throws — the caller degrades to the blockquote fallback in that case.
271
+ async function resolveKakaoReplyTarget(
272
+ client: Pick<KakaoTalkClient, 'getMessages'>,
273
+ chatId: string,
274
+ externalMessageId: string,
275
+ logger: KakaotalkAdapterLogger,
276
+ ): Promise<KakaoReplyTarget | undefined> {
277
+ try {
278
+ const messages = await client.getMessages(chatId, { count: KAKAO_REPLY_LOOKUP_COUNT })
279
+ const target = messages.find((m) => m.log_id === externalMessageId)
280
+ if (target === undefined) {
281
+ logger.warn(`[kakaotalk] reply target log_id=${externalMessageId} not in last ${KAKAO_REPLY_LOOKUP_COUNT}`)
282
+ return undefined
283
+ }
284
+ return { log_id: target.log_id, author_id: target.author_id, message: target.message, type: target.type }
285
+ } catch (err) {
286
+ logger.warn(`[kakaotalk] reply target lookup failed: ${describe(err)}`)
287
+ return undefined
288
+ }
289
+ }
290
+
242
291
  export function createKakaoHistoryCallback(deps: {
243
292
  client: Pick<KakaoTalkClient, 'getMessages'>
244
293
  logger: KakaotalkAdapterLogger
@@ -251,8 +251,12 @@ export function createOutboundCallback(deps: {
251
251
 
252
252
  try {
253
253
  const rendered = toTelegramMarkdownV2(text)
254
- const sendOptions: { message_thread_id?: number; parse_mode: 'MarkdownV2' } = { parse_mode: 'MarkdownV2' }
254
+ const sendOptions: { message_thread_id?: number; reply_to_message_id?: number; parse_mode: 'MarkdownV2' } = {
255
+ parse_mode: 'MarkdownV2',
256
+ }
255
257
  if (threadId !== undefined) sendOptions.message_thread_id = threadId
258
+ const replyToId = parseTelegramMessageId(msg.replyTo?.externalMessageId)
259
+ if (replyToId !== undefined) sendOptions.reply_to_message_id = replyToId
256
260
  const sent = await client.sendMessage(msg.chat, rendered, sendOptions)
257
261
  logger.info(`[telegram-bot] sent message_id=${sent.message_id} ${tag}`)
258
262
  return { ok: true }
@@ -270,6 +274,12 @@ function parseThreadId(thread: string | null | undefined): number | undefined {
270
274
  return Number.isFinite(n) ? n : undefined
271
275
  }
272
276
 
277
+ function parseTelegramMessageId(id: string | null | undefined): number | undefined {
278
+ if (id === null || id === undefined || id === '') return undefined
279
+ const n = Number(id)
280
+ return Number.isInteger(n) && n > 0 ? n : undefined
281
+ }
282
+
273
283
  type TelegramFileResponse = {
274
284
  ok: boolean
275
285
  result?: { file_id: string; file_unique_id: string; file_size?: number; file_path?: string }