typeclaw 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -46,7 +46,7 @@
46
46
  "@mariozechner/pi-coding-agent": "^0.67.3",
47
47
  "@mariozechner/pi-tui": "^0.67.3",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "agent-messenger": "2.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",
@@ -12,7 +12,17 @@ TypeClaw is domain-agnostic — your purpose is defined by \`IDENTITY.md\`, your
12
12
  - **AGENTS.md** *(read on demand)* — your operating manual. Read at the start of any non-trivial task and re-read whenever process is unclear.
13
13
  - **\`memory/topics/\`** *(always injected below, READ-ONLY)* — sharded long-term memory, owned by the dreaming subagent. To capture something memorable, surface it in your reply or let the memory-logger append to \`memory/streams/\`; never edit memory shards directly.
14
14
 
15
- If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never memory shards.
15
+ If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never memory shards. **Use this routing when you have something durable to record:**
16
+
17
+ - *role, function, scope of work, who you are to this user* → IDENTITY.md
18
+ - *voice, tone, register, language preferences, persona quirks* → SOUL.md
19
+ - *facts about the user (name, timezone, projects, preferences they hold across tasks)* → USER.md
20
+ - *working conventions, repeatable procedures, "always do X" rules, things future-you needs to read before acting* → AGENTS.md
21
+ - *one-off context for this conversation only* → don't write a file; it'll be captured in \`memory/streams/\` automatically
22
+
23
+ When in doubt between SOUL.md and AGENTS.md: if it describes *how you sound*, it's SOUL; if it describes *how you work*, it's AGENTS. Tone preferences ("be more terse") go to SOUL.md; process rules ("always run tests before committing") go to AGENTS.md.
24
+
25
+ **Edit discipline.** Prefer rewriting in place to growing files. SOUL.md should stay short — a paragraph or two; if it's drifting past a screen, you're using it as a scratchpad and the model that reads it will start ignoring the back half. IDENTITY.md is similar — a few lines of who you are, not a résumé. AGENTS.md is the one allowed to grow. Don't rewrite SOUL.md on the first piece of tone feedback in a session — wait until the user repeats a preference or asks you directly to update it; a single off-day request isn't a durable change.
16
26
 
17
27
  ## Your workspace
18
28
 
@@ -39,10 +39,13 @@ export type SkipResponseDetails = {
39
39
  // `skip_response` is preferred whenever the model has a reason worth
40
40
  // recording. See session-origin.ts for the prompt-level decision rule.
41
41
  //
42
- // Order-dependence with `channel_reply`/`channel_send`: once `skip_response`
43
- // fires in a turn, the router rejects any subsequent tool-source send for
44
- // the same turn with `SKIP_RESPONSE_LOCK_ERROR`. The model gets a clear
45
- // error and learns to commit on the next turn instead of mid-turn.
42
+ // Order-dependence with `channel_reply`/`channel_send` is asymmetric:
43
+ // - skip BEFORE any send → commits to silence; the router rejects any
44
+ // subsequent tool-source send this turn with `SKIP_RESPONSE_LOCK_ERROR`.
45
+ // - skip AFTER a send accepted as a terminal no-op (`recorded-after-send`).
46
+ // The earlier reply stands; this posts nothing and ends the turn. Rejecting
47
+ // it (the old behavior) drove a livelock: denied a clean silent exit, the
48
+ // model re-sent, got re-denied on the next skip, and repeated to the cap.
46
49
  export function createSkipResponseTool({
47
50
  router,
48
51
  sessionId,
@@ -55,12 +58,14 @@ export function createSkipResponseTool({
55
58
  'Decline to send a user-facing reply this turn, with a logged reason. Use this ' +
56
59
  'instead of narrating "I have nothing to add" / "I will stay quiet" in your visible ' +
57
60
  'response. The reason is written to host logs (visible via `typeclaw logs -f`) but ' +
58
- 'never delivered to the user. The contract is bidirectional: after calling this, any ' +
59
- '`channel_reply` / `channel_send` in the same turn will be rejected, AND calling this ' +
60
- 'after a `channel_reply` / `channel_send` has already landed in this turn will also ' +
61
- 'be rejected commit to silence or commit to replying, not both. Decide before you ' +
62
- 'send, and call this as your terminal tool when you decide to stay silent. Prefer ' +
63
- 'this over the `NO_REPLY` text sentinel whenever you have a reason worth recording.',
61
+ 'never delivered to the user. If you call this BEFORE sending anything this turn, it ' +
62
+ 'commits you to silence and any later `channel_reply` / `channel_send` in the same ' +
63
+ 'turn is rejected. If you call it AFTER a reply has already landed this turn (e.g. you ' +
64
+ 'posted an ack and now want to wait quietly for a backgrounded subagent), it is ' +
65
+ 'accepted as a terminal no-op: your earlier reply stands, nothing further is sent, and ' +
66
+ 'your turn ends. Either way, call this as your terminal tool when you decide to stop ' +
67
+ 'talking — do NOT keep sending "still working" updates. Prefer this over the ' +
68
+ '`NO_REPLY` text sentinel whenever you have a reason worth recording.',
64
69
  parameters: Type.Object({
65
70
  reason: Type.String({
66
71
  description:
@@ -85,33 +90,20 @@ export function createSkipResponseTool({
85
90
  }
86
91
 
87
92
  const result = router.markTurnSkipped({ parentSessionId: sessionId, reason })
88
- if (result.kind === 'send-already-happened') {
89
- // Symmetric counterpart of the send-after-skip lock in `router.send()`.
90
- // The model already committed to replying earlier in this turn; calling
91
- // skip_response now would land the reply AND claim silence at the same
92
- // time, which is the contract violation the lock exists to prevent.
93
- // Surface a clear error and refuse to stamp the flag so the rest of
94
- // the turn behaves as a normal reply turn.
95
- logger.warn(
96
- formatChannelToolFailure(
97
- 'skip_response',
98
- `channel send already happened this turn (reason=${JSON.stringify(reason)})`,
99
- ),
100
- )
101
- const details: SkipResponseDetails = {
102
- ok: false,
103
- suppressed: false,
104
- reason,
105
- error: 'send-already-happened',
106
- }
93
+ if (result.kind === 'recorded-after-send') {
94
+ // Reply-first skip: an ack already landed; this just ends the turn
95
+ // quietly. Not suppressed (the reply stands) and not an error (erroring
96
+ // here is what drove the historical re-send livelock). Router logged it.
97
+ const details: SkipResponseDetails = { ok: true, suppressed: false, reason }
107
98
  return {
108
99
  content: [
109
100
  {
110
101
  type: 'text' as const,
111
102
  text:
112
- 'skip_response denied: you already sent a channel reply in this turn. ' +
113
- 'Commit to silence or commit to replying, not both. ' +
114
- 'End your turn now; the reply you already sent stands.',
103
+ 'skip_response accepted: your earlier channel reply this turn stands, and ' +
104
+ 'no further message will be sent. End your turn now — do not send "still ' +
105
+ 'working" updates while a backgrounded subagent runs; the completion ' +
106
+ 'reminder will wake you when it finishes.',
115
107
  },
116
108
  ],
117
109
  details,
@@ -103,6 +103,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
103
103
  if (params.description !== undefined) payload.description = params.description
104
104
 
105
105
  const startedAt = now()
106
+ const spawnedByRole = permissions?.resolveRole(origin)
106
107
  const { handle, completion } = startSubagent(subagentName, {
107
108
  registry,
108
109
  createSessionForSubagent,
@@ -110,6 +111,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
110
111
  userPrompt: params.prompt,
111
112
  payload: subagent.payloadSchema ? payload : undefined,
112
113
  parentSessionId,
114
+ ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
113
115
  ...(origin !== undefined ? { spawnedByOrigin: origin } : {}),
114
116
  taskId,
115
117
  })
@@ -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) {
@@ -45,7 +45,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
45
45
 
46
46
  const selfId = options.selfId()
47
47
  const selfLogin = options.selfLogin()
48
- const author = readAuthor(payload)
48
+ const author = readAuthor(event, payload)
49
49
  if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
50
50
  options.logger.info(
51
51
  `[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
@@ -363,13 +363,52 @@ function readRepository(payload: Record<string, unknown>): { owner: string; name
363
363
  return { owner: ownerLogin, name }
364
364
  }
365
365
 
366
- function readAuthor(payload: Record<string, unknown>): GithubUser | null {
367
- const candidates = [payload.comment, payload.issue, payload.pull_request, payload.discussion, payload.review]
368
- for (const candidate of candidates) {
366
+ function readAuthor(event: string, payload: Record<string, unknown>): GithubUser | null {
367
+ for (const candidate of eventAuthorCandidates(event, payload)) {
369
368
  const user = readUser(readRecord(candidate)?.user)
370
369
  if (user !== null) return user
371
370
  }
372
- return null
371
+ // Every GitHub webhook payload carries `sender` — the actor who triggered the
372
+ // delivery. It is the universal fallback so events not enumerated above (and
373
+ // any future ones the user adds to eventAllowlist) still drop self-authored
374
+ // deliveries instead of slipping past the guard.
375
+ return readUser(payload.sender)
376
+ }
377
+
378
+ // Maps each event to the entity whose `user` is the true author of THIS event,
379
+ // listed before broader containers. A pull_request_review payload ships both
380
+ // `pull_request` (the PR author) and `review` (the reviewer); the self-author
381
+ // drop must see the reviewer, so `review` must come first. PR #455's flat order
382
+ // (`pull_request` before `review`) made a self-review on someone else's PR
383
+ // resolve to the PR author, slip past the drop, and loop (see PR #460).
384
+ //
385
+ // `pull_request` and `pull_request_review_thread` carry only the `pull_request`
386
+ // container, whose `user` is the PR OPENER — not the actor of this delivery.
387
+ // For these events the self-author question is "who triggered the action?"
388
+ // (review_requested, edited, reopened, resolved, …), which is always
389
+ // `payload.sender`, never the opener. Mapping them to `[]` makes readAuthor
390
+ // skip the opener and fall through to the `sender` fallback. PR #462's
391
+ // `['pull_request']` resolved to the opener, so a human action on a
392
+ // bot-opened PR matched the bot and was wrongly dropped (the inbound landed
393
+ // as awareness-only "Recent context" and the agent never replied).
394
+ const PRIMARY_AUTHOR_KEYS: Record<string, readonly string[]> = {
395
+ issue_comment: ['comment'],
396
+ pull_request_review_comment: ['comment'],
397
+ discussion_comment: ['comment'],
398
+ commit_comment: ['comment'],
399
+ pull_request_review: ['review'],
400
+ pull_request_review_thread: [],
401
+ issues: ['issue'],
402
+ pull_request: [],
403
+ discussion: ['discussion'],
404
+ release: ['release'],
405
+ }
406
+
407
+ const FALLBACK_AUTHOR_KEYS = ['comment', 'review', 'issue', 'pull_request', 'discussion', 'release'] as const
408
+
409
+ function eventAuthorCandidates(event: string, payload: Record<string, unknown>): unknown[] {
410
+ const keys = PRIMARY_AUTHOR_KEYS[event] ?? FALLBACK_AUTHOR_KEYS
411
+ return keys.map((key) => payload[key])
373
412
  }
374
413
 
375
414
  // Matches by id OR login. Issue #452 captured a self-responding loop where
@@ -53,6 +53,14 @@ export type GithubAdapterOptions = {
53
53
  // Test-only: replaces the wall-clock sleep used for the registration
54
54
  // delay above. Production leaves it undefined and we use `setTimeout`.
55
55
  sleep?: (ms: number) => Promise<void>
56
+ // How often to proactively refresh the token and update GH_TOKEN
57
+ // when the adapter is running but has not made an outbound API call
58
+ // recently. Zero disables the background refresh entirely.
59
+ // Default: 30 minutes.
60
+ tokenRefreshIntervalMs?: number
61
+ // Test-only: replaces `setInterval` so tests can control when the
62
+ // background refresh fires without waiting on real wall-clock time.
63
+ setInterval?: (handler: () => void, ms: number) => { clear: () => void }
56
64
  }
57
65
 
58
66
  export type GithubAdapter = {
@@ -68,6 +76,7 @@ const consoleLogger: GithubAdapterLogger = {
68
76
  }
69
77
 
70
78
  const DEFAULT_WEBHOOK_REGISTRATION_DELAY_MS = 2_000
79
+ const DEFAULT_TOKEN_REFRESH_INTERVAL_MS = 30 * 60 * 1000
71
80
 
72
81
  export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapter {
73
82
  const logger = options.logger ?? consoleLogger
@@ -83,6 +92,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
83
92
  let selfLogin: string | null = null
84
93
  let started = false
85
94
  let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
95
+ let tokenRefreshTimer: { clear: () => void } | null = null
86
96
  const workspaceByChat = new Map<string, string>()
87
97
 
88
98
  const rememberWorkspace = (workspace: string, chat: string): void => {
@@ -168,6 +178,24 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
168
178
  // automatically when within 5 minutes of expiry.
169
179
  process.env.GH_TOKEN = await auth.token()
170
180
  started = true
181
+ // Keep GH_TOKEN warm even when the adapter is only receiving inbound
182
+ // webhooks and not making outbound API calls. This prevents `gh` CLI
183
+ // calls from the agent from failing with 401 after the token expires.
184
+ const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
185
+ if (tokenRefreshIntervalMs > 0) {
186
+ const refresh = () => {
187
+ tokenFn().catch((err) => {
188
+ logger.error(`[github] periodic token refresh failed: ${err instanceof Error ? err.message : String(err)}`)
189
+ })
190
+ }
191
+ const setIntervalFn =
192
+ options.setInterval ??
193
+ ((handler: () => void, ms: number) => {
194
+ const timer = setInterval(handler, ms)
195
+ return { clear: () => clearInterval(timer) }
196
+ })
197
+ tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
198
+ }
171
199
  logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
172
200
  // Best-effort: App-only preflight that compares the installation's granted
173
201
  // permissions against the configured eventAllowlist and warns about gaps.
@@ -241,6 +269,10 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
241
269
  logDeregistrationOutcome(logger, deregistration)
242
270
  managedHooks = []
243
271
  }
272
+ if (tokenRefreshTimer !== null) {
273
+ tokenRefreshTimer.clear()
274
+ tokenRefreshTimer = null
275
+ }
244
276
  await auth.dispose()
245
277
  delete process.env.GH_TOKEN
246
278
  server = null
@@ -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