typeclaw 0.23.0 → 0.24.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +91 -22
  3. package/src/agent/plugin-tools.ts +38 -2
  4. package/src/agent/restart/index.ts +15 -3
  5. package/src/agent/restart-handoff/index.ts +110 -12
  6. package/src/agent/subagent-completion-reminder.ts +3 -1
  7. package/src/agent/subagents.ts +44 -1
  8. package/src/agent/system-prompt.ts +4 -0
  9. package/src/agent/todo/continuation-policy.ts +242 -0
  10. package/src/agent/todo/continuation-state.ts +87 -0
  11. package/src/agent/todo/continuation-wiring.ts +113 -0
  12. package/src/agent/todo/continuation.ts +71 -0
  13. package/src/agent/todo/scope.ts +77 -0
  14. package/src/agent/todo/store.ts +98 -0
  15. package/src/agent/tool-not-found-nudge.ts +119 -0
  16. package/src/agent/tools/channel-reply.ts +51 -0
  17. package/src/agent/tools/restart.ts +11 -4
  18. package/src/agent/tools/todo/index.ts +119 -0
  19. package/src/bundled-plugins/backup/runner.ts +1 -1
  20. package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
  21. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  22. package/src/channels/adapters/discord-bot.ts +25 -3
  23. package/src/channels/adapters/github/inbound.ts +161 -10
  24. package/src/channels/adapters/github/index.ts +10 -0
  25. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  26. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  27. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  28. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  29. package/src/channels/adapters/slack-bot.ts +67 -8
  30. package/src/channels/manager.ts +8 -2
  31. package/src/channels/router.ts +445 -22
  32. package/src/channels/schema.ts +20 -4
  33. package/src/channels/types.ts +68 -0
  34. package/src/cli/inspect-controller.ts +7 -0
  35. package/src/cli/inspect.ts +2 -1
  36. package/src/commands/index.ts +9 -0
  37. package/src/init/gitignore.ts +5 -2
  38. package/src/inspect/index.ts +22 -0
  39. package/src/run/index.ts +60 -5
  40. package/src/sandbox/build.ts +10 -0
  41. package/src/sandbox/index.ts +2 -0
  42. package/src/sandbox/policy.ts +10 -0
  43. package/src/sandbox/writable-zones.ts +78 -0
  44. package/src/server/index.ts +118 -4
  45. package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
  46. package/typeclaw.schema.json +10 -0
@@ -1,5 +1,6 @@
1
1
  import { createHmac, timingSafeEqual } from 'node:crypto'
2
2
 
3
+ import type { GithubReviewOn } from '@/channels/schema'
3
4
  import type { InboundMessage } from '@/channels/types'
4
5
 
5
6
  import type { GithubAuthContext } from './auth'
@@ -23,6 +24,14 @@ export type GithubWebhookHandlerOptions = {
23
24
  // an appended operator-policy note telling the agent not to submit an APPROVE
24
25
  // review; the github skill keys off that note to downgrade approve→COMMENT.
25
26
  allowApprove?: () => boolean
27
+ // Which pull_request action triggers an agent code review. Defaults to
28
+ // 'review_requested' when omitted, preserving the request-driven behavior.
29
+ // 'opened' additionally wakes the bot to review every PR the moment it opens;
30
+ // 'off' suppresses the dedicated review-trigger synthesis entirely (an
31
+ // explicit review_requested no longer wakes a session). Orthogonal to the
32
+ // eventAllowlist (the outer "process this webhook?" gate) — this is the inner
33
+ // "does an admitted pull_request event become a review-trigger inbound?" gate.
34
+ reviewOn?: () => GithubReviewOn
26
35
  route: (message: InboundMessage) => void
27
36
  logger: GithubInboundLogger
28
37
  // Optional: resolves whether the bot is a member of the given team. When
@@ -75,6 +84,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
75
84
  const classified = classifyGithubInbound(event, payload, selfLogin, {
76
85
  teamIsBotMember,
77
86
  authType: options.authType?.() ?? 'pat',
87
+ reviewOn: options.reviewOn?.() ?? 'review_requested',
78
88
  })
79
89
  if (classified === null) return ok()
80
90
 
@@ -173,7 +183,7 @@ export function classifyGithubInbound(
173
183
  event: string,
174
184
  payload: Record<string, unknown>,
175
185
  selfLogin: string | null,
176
- options?: { teamIsBotMember?: boolean; authType?: 'pat' | 'app' },
186
+ options?: { teamIsBotMember?: boolean; authType?: 'pat' | 'app'; reviewOn?: GithubReviewOn },
177
187
  ): InboundMessage | null {
178
188
  const repository = readRepository(payload)
179
189
  if (repository === null) return null
@@ -248,14 +258,22 @@ export function classifyGithubInbound(
248
258
  const number = readNumber(issue, 'number')
249
259
  const id = readNumber(issue, 'id') ?? number
250
260
  if (number === null || id === null) return null
261
+ const action = readString(payload, 'action')
262
+ const opener = readUser(issue.user)
263
+ const hasBody = readString(issue, 'body')?.trim() ? true : false
264
+ const text =
265
+ action === 'opened'
266
+ ? bodyOrOpenedTitle(issue.body, opener, 'issue', number, readString(issue, 'title'))
267
+ : issue.body
251
268
  return buildInbound(
252
269
  { ...base, chat: `issue:${number}`, thread: null },
253
- issue.body,
270
+ text,
254
271
  id,
255
- readUser(issue.user),
272
+ opener,
256
273
  selfLogin,
257
274
  issue.created_at,
258
275
  { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
276
+ action === 'opened' && !hasBody,
259
277
  )
260
278
  }
261
279
 
@@ -266,7 +284,12 @@ export function classifyGithubInbound(
266
284
  const id = readNumber(pr, 'id') ?? number
267
285
  if (number === null || id === null) return null
268
286
  const action = readString(payload, 'action')
287
+ const reviewOn = options?.reviewOn ?? 'review_requested'
269
288
  if (action === 'review_requested' || action === 'review_request_removed') {
289
+ // `off` disables the dedicated review trigger: these two actions exist
290
+ // only to drive review-request behavior here, so under `off` they wake no
291
+ // session rather than falling through to awareness-only context.
292
+ if (reviewOn === 'off') return null
270
293
  return classifyReviewRequest({
271
294
  action,
272
295
  payload,
@@ -278,14 +301,30 @@ export function classifyGithubInbound(
278
301
  teamIsBotMember: options?.teamIsBotMember,
279
302
  })
280
303
  }
304
+ if (action === 'opened' && reviewOn === 'opened') {
305
+ const trigger = classifyOpenedReviewTrigger({
306
+ payload,
307
+ pr,
308
+ number,
309
+ base,
310
+ selfLogin,
311
+ authType: options?.authType ?? 'pat',
312
+ })
313
+ if (trigger !== null) return trigger
314
+ }
315
+ const opener = readUser(pr.user)
316
+ const hasBody = readString(pr, 'body')?.trim() ? true : false
317
+ const prText =
318
+ action === 'opened' ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
281
319
  return buildInbound(
282
320
  { ...base, chat: `pr:${number}`, thread: null },
283
- pr.body,
321
+ prText,
284
322
  id,
285
- readUser(pr.user),
323
+ opener,
286
324
  selfLogin,
287
325
  pr.created_at,
288
326
  { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
327
+ action === 'opened' && !hasBody,
289
328
  )
290
329
  }
291
330
 
@@ -296,14 +335,23 @@ export function classifyGithubInbound(
296
335
  const number = readNumber(pr, 'number')
297
336
  const id = readNumber(review, 'id')
298
337
  if (number === null || id === null) return null
338
+ const reviewer = readUser(review.user)
339
+ const body = readString(review, 'body')
340
+ const hasBody = body !== null && body.trim() !== ''
341
+ const text = hasBody
342
+ ? body
343
+ : reviewer !== null
344
+ ? synthesizeReviewStateText(reviewer.login, number, readString(pr, 'title'), readString(review, 'state'))
345
+ : ''
299
346
  return buildInbound(
300
347
  { ...base, chat: `pr:${number}`, thread: null },
301
- review.body,
348
+ text,
302
349
  id,
303
- readUser(review.user),
350
+ reviewer,
304
351
  selfLogin,
305
352
  review.submitted_at,
306
353
  null,
354
+ !hasBody,
307
355
  )
308
356
  }
309
357
 
@@ -313,14 +361,22 @@ export function classifyGithubInbound(
313
361
  const number = readNumber(discussion, 'number')
314
362
  const id = readNumber(discussion, 'id') ?? number
315
363
  if (number === null || id === null) return null
364
+ const action = readString(payload, 'action')
365
+ const opener = readUser(discussion.user)
366
+ const hasBody = readString(discussion, 'body')?.trim() ? true : false
367
+ const text =
368
+ action === 'created'
369
+ ? bodyOrOpenedTitle(discussion.body, opener, 'discussion', number, readString(discussion, 'title'))
370
+ : discussion.body
316
371
  return buildInbound(
317
372
  { ...base, chat: `discussion:${number}`, thread: null },
318
- discussion.body,
373
+ text,
319
374
  id,
320
- readUser(discussion.user),
375
+ opener,
321
376
  selfLogin,
322
377
  discussion.created_at,
323
378
  null,
379
+ action === 'created' && !hasBody,
324
380
  )
325
381
  }
326
382
 
@@ -418,6 +474,53 @@ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null
418
474
  }
419
475
  }
420
476
 
477
+ type OpenedReviewTriggerInput = {
478
+ payload: Record<string, unknown>
479
+ pr: Record<string, unknown>
480
+ number: number
481
+ base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
482
+ selfLogin: string | null
483
+ authType: 'pat' | 'app'
484
+ }
485
+
486
+ function classifyOpenedReviewTrigger(input: OpenedReviewTriggerInput): InboundMessage | null {
487
+ const { payload, pr, number, base, selfLogin, authType } = input
488
+ if (selfLogin === null) return null
489
+ const sender = readUser(payload.sender) ?? readUser(pr.user)
490
+ if (sender === null) return null
491
+ // Defensive self-loop guard mirroring classifyReviewRequest: the handler-level
492
+ // self-author drop already discards bot-opened PRs, but the decoy account is a
493
+ // distinct login, so a decoy-opened PR would otherwise wake a self-review.
494
+ const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
495
+ if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
496
+
497
+ const title = readString(pr, 'title') ?? `#${number}`
498
+ const head = readString(readRecord(pr.head), 'ref')
499
+ const baseRef = readString(readRecord(pr.base), 'ref')
500
+ const branchSegment = head !== null && baseRef !== null ? ` Branch: ${head} → ${baseRef}.` : ''
501
+ const text =
502
+ `@${sender.login} opened PR #${number}: "${title}".${branchSegment}` +
503
+ ' Please review the changes line-by-line and post your feedback.'
504
+
505
+ const updatedAt = readString(pr, 'updated_at') ?? ''
506
+ const prId = readNumber(pr, 'id') ?? number
507
+ const externalMessageId = `pr-${prId}-opened-${updatedAt}`
508
+
509
+ return {
510
+ ...base,
511
+ chat: `pr:${number}`,
512
+ thread: null,
513
+ text,
514
+ externalMessageId,
515
+ authorId: String(sender.id),
516
+ authorName: sender.login,
517
+ authorIsBot: sender.type === 'Bot',
518
+ isBotMention: true,
519
+ replyToBotMessageId: null,
520
+ ts: updatedAt !== '' ? Date.parse(updatedAt) || 0 : 0,
521
+ }
522
+ }
523
+
421
524
  export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
422
525
 
423
526
  export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
@@ -440,9 +543,21 @@ function buildInbound(
440
543
  selfLogin: string | null,
441
544
  rawTs: unknown,
442
545
  reactionTarget: GithubReactionTarget | null,
546
+ synthesizedAwareness = false,
443
547
  ): InboundMessage | null {
444
548
  if (user === null) return null
445
549
  const text = typeof rawText === 'string' ? rawText : ''
550
+ // A body-less inbound reaches engagement as contentless text; in a solo-human
551
+ // channel the fallback engages on it and the agent replies with a generic
552
+ // greeting. The other adapters drop empty text at their classifier — this is
553
+ // the matching guard. Events whose empty body still carries signal (review
554
+ // state, opened-PR/issue title) synthesize non-empty text upstream and so
555
+ // never reach this drop.
556
+ if (text.trim() === '') return null
557
+ // Synthesized awareness lines carry an `@author` prefix describing who acted;
558
+ // that handle is the author, never a third-party mention of the bot, so the
559
+ // body-text mention heuristic must not fire on it.
560
+ const isBotMention = !synthesizedAwareness && selfLogin !== null && text.includes(`@${selfLogin}`)
446
561
  return {
447
562
  ...key,
448
563
  text,
@@ -451,12 +566,48 @@ function buildInbound(
451
566
  authorId: String(user.id),
452
567
  authorName: user.login,
453
568
  authorIsBot: user.type === 'Bot',
454
- isBotMention: selfLogin !== null && text.includes(`@${selfLogin}`),
569
+ isBotMention,
455
570
  replyToBotMessageId: null,
456
571
  ts: typeof rawTs === 'string' ? Date.parse(rawTs) || 0 : 0,
457
572
  }
458
573
  }
459
574
 
575
+ function bodyOrOpenedTitle(
576
+ rawBody: unknown,
577
+ opener: GithubUser | null,
578
+ kind: 'issue' | 'PR' | 'discussion',
579
+ number: number,
580
+ title: string | null,
581
+ ): string {
582
+ const body = typeof rawBody === 'string' ? rawBody : ''
583
+ if (body.trim() !== '' || opener === null) return body
584
+ const label = title !== null && title.trim() !== '' ? `: "${title}"` : ''
585
+ return `@${opener.login} opened ${kind} #${number}${label}.`
586
+ }
587
+
588
+ // Neutral phrasing per review state — must never imply a review was requested
589
+ // or that action is needed; a COMMENTED review in particular must not read as
590
+ // "please review", which is the review-request path's wording.
591
+ function synthesizeReviewStateText(
592
+ reviewer: string,
593
+ number: number,
594
+ title: string | null,
595
+ state: string | null,
596
+ ): string {
597
+ const label = title !== null && title.trim() !== '' ? `: "${title}"` : ''
598
+ // GitHub's pull_request_review webhook can send the state in either case
599
+ // depending on the source (webhook payload vs REST), so normalize before
600
+ // matching — an unmatched state would silently fall back to the neutral verb.
601
+ const normalized = state?.toLowerCase() ?? null
602
+ const verb =
603
+ normalized === 'approved'
604
+ ? 'approved'
605
+ : normalized === 'changes_requested'
606
+ ? 'requested changes on'
607
+ : 'submitted a review on'
608
+ return `@${reviewer} ${verb} PR #${number}${label}.`
609
+ }
610
+
460
611
  async function resolveTeamMembership(
461
612
  event: string,
462
613
  payload: Record<string, unknown>,
@@ -21,6 +21,7 @@ import {
21
21
  parseListHooksPermissionStatus,
22
22
  } from './permission-guidance'
23
23
  import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
24
+ import { createGithubReviewThreadResolver } from './review-thread-resolver'
24
25
  import { createTeamMembershipChecker } from './team-membership'
25
26
  import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
26
27
 
@@ -137,6 +138,11 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
137
138
  workspaceForChat: (chat) => workspaceByChat.get(chat) ?? null,
138
139
  })
139
140
  const membership = createGithubMembershipResolver({ token: authToken, fetchImpl })
141
+ const reviewThreadResolver = createGithubReviewThreadResolver({
142
+ token: authToken,
143
+ selfLogin: () => selfLogin,
144
+ fetchImpl,
145
+ })
140
146
  const channelNameResolver = createGithubChannelNameResolver({ token: authToken, fetchImpl })
141
147
  // GitHub addresses by `@login`, not the numeric id, so `username` carries
142
148
  // the login the model should type; the id is kept for completeness.
@@ -155,6 +161,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
155
161
  selfLogin: () => selfLogin,
156
162
  authType: () => options.secrets.auth.type,
157
163
  allowApprove: () => options.configRef().review.approve,
164
+ reviewOn: () => options.configRef().review.on,
158
165
  isBotInTeam,
159
166
  authToken,
160
167
  fetchImpl,
@@ -187,6 +194,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
187
194
  options.router.registerMembership('github', membership)
188
195
  options.router.registerChannelNameResolver('github', channelNameResolver)
189
196
  options.router.registerSelfIdentity('github', selfIdentityResolver)
197
+ options.router.registerReviewThreadResolver('github', reviewThreadResolver)
190
198
  options.router.registerFetchAttachment('github', fetchAttachment)
191
199
  try {
192
200
  server = (options.httpListenImpl ?? listenWithBun)(options.configRef().webhookPort, handler)
@@ -201,6 +209,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
201
209
  options.router.unregisterMembership('github', membership)
202
210
  options.router.unregisterChannelNameResolver('github', channelNameResolver)
203
211
  options.router.unregisterSelfIdentity('github', selfIdentityResolver)
212
+ options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
204
213
  options.router.unregisterFetchAttachment('github', fetchAttachment)
205
214
  await auth.dispose()
206
215
  delete process.env.GH_TOKEN
@@ -324,6 +333,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
324
333
  options.router.unregisterMembership('github', membership)
325
334
  options.router.unregisterChannelNameResolver('github', channelNameResolver)
326
335
  options.router.unregisterSelfIdentity('github', selfIdentityResolver)
336
+ options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
327
337
  options.router.unregisterFetchAttachment('github', fetchAttachment)
328
338
  await server?.stop()
329
339
  // Detach hooks AFTER closing the listener so any in-flight deliveries
@@ -0,0 +1,246 @@
1
+ import type { ReviewThreadResolveRequest, ReviewThreadResolveResult, ReviewThreadResolver } from '@/channels/types'
2
+
3
+ import type { GithubAuthContext } from './auth'
4
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
5
+
6
+ const GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`
7
+
8
+ // One page of review threads. `first: 100` is the GraphQL max; a busy PR can
9
+ // carry more, so the resolver paginates until it matches the root comment id
10
+ // or exhausts the pages — stopping early on a 404-equivalent (thread absent)
11
+ // rather than fabricating a node id.
12
+ const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{login}}}}}}}}`
13
+
14
+ const RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`
15
+
16
+ type ReviewThreadNode = {
17
+ id: string
18
+ isResolved: boolean
19
+ rootCommentId: number | null
20
+ rootAuthorLogin: string | null
21
+ }
22
+
23
+ type ThreadLookup =
24
+ | { kind: 'found'; thread: ReviewThreadNode }
25
+ | { kind: 'absent' }
26
+ | { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
27
+
28
+ export function createGithubReviewThreadResolver(deps: {
29
+ token: (context?: GithubAuthContext) => Promise<string>
30
+ selfLogin: () => string | null
31
+ fetchImpl?: typeof fetch
32
+ }): ReviewThreadResolver {
33
+ const fetchImpl = deps.fetchImpl ?? fetch
34
+ return async (req): Promise<ReviewThreadResolveResult> => {
35
+ if (req.adapter !== 'github') {
36
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
37
+ }
38
+ const target = parseTarget(req)
39
+ if (target === null) {
40
+ return {
41
+ ok: false,
42
+ error: `unparseable github review-thread target (chat=${req.chat}, root=${req.rootCommentId})`,
43
+ code: 'transient',
44
+ }
45
+ }
46
+ const selfLogin = deps.selfLogin()
47
+ if (selfLogin === null) {
48
+ return {
49
+ ok: false,
50
+ error: 'github self-identity not resolved; cannot verify thread authorship',
51
+ code: 'transient',
52
+ }
53
+ }
54
+
55
+ const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
56
+ const lookup = await findThread(fetchImpl, token, target)
57
+ if (lookup.kind === 'error') return lookup.result
58
+ if (lookup.kind === 'absent') {
59
+ return {
60
+ ok: false,
61
+ error: `no review thread rooted at comment ${target.rootCommentId} on ${req.chat}`,
62
+ code: 'no-match',
63
+ }
64
+ }
65
+
66
+ const thread = lookup.thread
67
+ // The load-bearing guard: only the bot may resolve the bot's own thread.
68
+ // Resolving a human reviewer's thread would erase their open question.
69
+ if (thread.rootAuthorLogin !== selfLogin) {
70
+ return {
71
+ ok: false,
72
+ error: `refusing to resolve thread authored by @${thread.rootAuthorLogin ?? 'unknown'} (not @${selfLogin})`,
73
+ code: 'not-author',
74
+ }
75
+ }
76
+ if (thread.isResolved) return { ok: true, alreadyResolved: true }
77
+
78
+ return await runResolveMutation(fetchImpl, token, thread.id)
79
+ }
80
+ }
81
+
82
+ type ResolveTarget = { owner: string; repo: string; prNumber: number; rootCommentId: number }
83
+
84
+ function parseTarget(req: ReviewThreadResolveRequest): ResolveTarget | null {
85
+ const [owner, repo, ...rest] = req.workspace.split('/')
86
+ if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
87
+ const prMatch = /^pr:(\d+)$/.exec(req.chat)
88
+ if (prMatch === null) return null
89
+ const prNumber = parseDecimalId(prMatch[1])
90
+ const rootCommentId = parseDecimalId(req.rootCommentId)
91
+ if (prNumber === null || rootCommentId === null) return null
92
+ return { owner, repo, prNumber, rootCommentId }
93
+ }
94
+
95
+ // Strict decimal-id parse: `Number()` would coerce '' -> 0, '1e2' -> 100, and
96
+ // silently round ids past 2^53 (GitHub comment ids are large), any of which
97
+ // could match the WRONG thread. Demand a plain run of digits and a safe
98
+ // integer, so a malformed or oversized id fails closed (no resolution) rather
99
+ // than resolving a collided thread.
100
+ function parseDecimalId(value: string | undefined): number | null {
101
+ if (value === undefined || !/^\d+$/.test(value)) return null
102
+ const n = Number(value)
103
+ return Number.isSafeInteger(n) ? n : null
104
+ }
105
+
106
+ async function findThread(fetchImpl: typeof fetch, token: string, target: ResolveTarget): Promise<ThreadLookup> {
107
+ let after: string | null = null
108
+ for (;;) {
109
+ let response: Response
110
+ try {
111
+ response = await fetchImpl(GRAPHQL_ENDPOINT, {
112
+ method: 'POST',
113
+ headers: githubJsonHeaders(token),
114
+ body: JSON.stringify({
115
+ query: THREADS_QUERY,
116
+ variables: { owner: target.owner, name: target.repo, number: target.prNumber, after },
117
+ }),
118
+ })
119
+ } catch (err) {
120
+ return {
121
+ kind: 'error',
122
+ result: { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' },
123
+ }
124
+ }
125
+ const parsed = await parseThreadsPage(response)
126
+ if (parsed.kind === 'error') return { kind: 'error', result: parsed.result }
127
+
128
+ for (const node of parsed.nodes) {
129
+ if (node.rootCommentId === target.rootCommentId) return { kind: 'found', thread: node }
130
+ }
131
+ if (!parsed.hasNextPage || parsed.endCursor === null) return { kind: 'absent' }
132
+ after = parsed.endCursor
133
+ }
134
+ }
135
+
136
+ type ThreadsPage =
137
+ | { kind: 'ok'; nodes: ReviewThreadNode[]; hasNextPage: boolean; endCursor: string | null }
138
+ | { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
139
+
140
+ async function parseThreadsPage(response: Response): Promise<ThreadsPage> {
141
+ if (!response.ok) {
142
+ const text = await response.text().catch(() => '')
143
+ return {
144
+ kind: 'error',
145
+ result: {
146
+ ok: false,
147
+ error: `GitHub GraphQL ${response.status}${text !== '' ? `: ${text}` : ''}`,
148
+ code: classifyStatus(response.status),
149
+ },
150
+ }
151
+ }
152
+ const body = (await response.json().catch(() => null)) as GraphqlThreadsResponse | null
153
+ if (body === null)
154
+ return { kind: 'error', result: { ok: false, error: 'GitHub GraphQL returned non-JSON', code: 'transient' } }
155
+ if (body.errors !== undefined && body.errors.length > 0) {
156
+ return {
157
+ kind: 'error',
158
+ result: {
159
+ ok: false,
160
+ error: `GitHub GraphQL error: ${body.errors.map((e) => e.message).join('; ')}`,
161
+ code: 'transient',
162
+ },
163
+ }
164
+ }
165
+ const connection = body.data?.repository?.pullRequest?.reviewThreads
166
+ if (connection === undefined)
167
+ return {
168
+ kind: 'error',
169
+ result: { ok: false, error: 'GitHub GraphQL response missing reviewThreads', code: 'transient' },
170
+ }
171
+ const nodes = connection.nodes.map((n) => {
172
+ const root = n.comments.nodes[0]
173
+ return {
174
+ id: n.id,
175
+ isResolved: n.isResolved,
176
+ rootCommentId: root?.databaseId ?? null,
177
+ rootAuthorLogin: root?.author?.login ?? null,
178
+ }
179
+ })
180
+ return { kind: 'ok', nodes, hasNextPage: connection.pageInfo.hasNextPage, endCursor: connection.pageInfo.endCursor }
181
+ }
182
+
183
+ async function runResolveMutation(
184
+ fetchImpl: typeof fetch,
185
+ token: string,
186
+ threadId: string,
187
+ ): Promise<ReviewThreadResolveResult> {
188
+ let response: Response
189
+ try {
190
+ response = await fetchImpl(GRAPHQL_ENDPOINT, {
191
+ method: 'POST',
192
+ headers: githubJsonHeaders(token),
193
+ body: JSON.stringify({ query: RESOLVE_MUTATION, variables: { threadId } }),
194
+ })
195
+ } catch (err) {
196
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
197
+ }
198
+ if (!response.ok) {
199
+ const text = await response.text().catch(() => '')
200
+ return {
201
+ ok: false,
202
+ error: `GitHub GraphQL ${response.status}${text !== '' ? `: ${text}` : ''}`,
203
+ code: classifyStatus(response.status),
204
+ }
205
+ }
206
+ const body = (await response.json().catch(() => null)) as GraphqlResolveResponse | null
207
+ if (body === null) return { ok: false, error: 'GitHub GraphQL returned non-JSON', code: 'transient' }
208
+ if (body.errors !== undefined && body.errors.length > 0) {
209
+ return {
210
+ ok: false,
211
+ error: `GitHub GraphQL error: ${body.errors.map((e) => e.message).join('; ')}`,
212
+ code: 'transient',
213
+ }
214
+ }
215
+ if (body.data?.resolveReviewThread?.thread?.isResolved === true) return { ok: true }
216
+ return { ok: false, error: 'resolveReviewThread mutation did not report isResolved', code: 'transient' }
217
+ }
218
+
219
+ function classifyStatus(status: number): 'permission-denied' | 'not-found' | 'transient' {
220
+ if (status === 401 || status === 403) return 'permission-denied'
221
+ if (status === 404) return 'not-found'
222
+ return 'transient'
223
+ }
224
+
225
+ type GraphqlThreadsResponse = {
226
+ data?: {
227
+ repository?: {
228
+ pullRequest?: {
229
+ reviewThreads?: {
230
+ pageInfo: { hasNextPage: boolean; endCursor: string | null }
231
+ nodes: Array<{
232
+ id: string
233
+ isResolved: boolean
234
+ comments: { nodes: Array<{ databaseId?: number; author?: { login?: string } }> }
235
+ }>
236
+ }
237
+ }
238
+ }
239
+ }
240
+ errors?: Array<{ message: string }>
241
+ }
242
+
243
+ type GraphqlResolveResponse = {
244
+ data?: { resolveReviewThread?: { thread?: { id: string; isResolved: boolean } } }
245
+ errors?: Array<{ message: string }>
246
+ }
@@ -1,8 +1,8 @@
1
- import type { KakaoTalkPushMessageEvent } from 'agent-messenger/kakaotalk'
1
+ import { KAKAO_MESSAGE_TYPE, 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 { InboundAttachment, InboundMessage } from '@/channels/types'
5
+ import type { InboundAttachment, InboundMessage, InboundReferenceContext } from '@/channels/types'
6
6
 
7
7
  export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect' | 'bot_message'
8
8
 
@@ -49,7 +49,9 @@ export function classifyInbound(
49
49
  return { kind: 'drop', reason: 'bot_message' }
50
50
  }
51
51
 
52
- const text = event.message ?? ''
52
+ const rawText = event.message ?? ''
53
+ const replyContext = parseReplyContext(event, context.selfUserId)
54
+ const text = rawText
53
55
  if (text === '') return { kind: 'drop', reason: 'empty_text' }
54
56
 
55
57
  const chatInfo = context.lookupChat(event.chat_id)
@@ -64,7 +66,7 @@ export function classifyInbound(
64
66
  // mention (see engagement.ts: alias is unconditional and ranks alongside
65
67
  // explicit triggers). Without aliases configured, only `reply` and `dm`
66
68
  // triggers can fire on KakaoTalk.
67
- const aliasMatched = matchesAnyAlias(text, context.selfAliases ?? [])
69
+ const aliasMatched = matchesAnyAlias(rawText, context.selfAliases ?? [])
68
70
 
69
71
  return {
70
72
  kind: 'route',
@@ -74,6 +76,7 @@ export function classifyInbound(
74
76
  chat: event.chat_id,
75
77
  thread: null,
76
78
  text,
79
+ ...referenceContextPayload(replyContext),
77
80
  ...(context.attachments !== undefined && context.attachments.length > 0
78
81
  ? { attachments: context.attachments }
79
82
  : {}),
@@ -82,9 +85,9 @@ export function classifyInbound(
82
85
  authorName: event.author_name ?? String(event.author_id),
83
86
  authorIsBot: false,
84
87
  isBotMention: aliasMatched,
85
- replyToBotMessageId: null,
88
+ replyToBotMessageId: replyContext?.target === 'bot' ? replyContext.logId : null,
86
89
  mentionsOthers: false,
87
- replyToOtherMessageId: null,
90
+ replyToOtherMessageId: replyContext?.target === 'other' ? replyContext.logId : null,
88
91
  isDm: chatInfo.isDm,
89
92
  // SDK delivers `sent_at` in Unix seconds (LOCO `sendAt`); contract
90
93
  // wants ms (see `src/channels/types.ts`). Without `* 1000`, ms-based
@@ -93,3 +96,61 @@ export function classifyInbound(
93
96
  },
94
97
  }
95
98
  }
99
+
100
+ type ParsedReplyContext = {
101
+ logId: string
102
+ authorId: string
103
+ authorName: string
104
+ quotedText: string | null
105
+ target: 'bot' | 'other'
106
+ }
107
+
108
+ function parseReplyContext(event: KakaoTalkPushMessageEvent, selfUserId: string): ParsedReplyContext | null {
109
+ if (event.message_type !== KAKAO_MESSAGE_TYPE.REPLY) return null
110
+ if (event.attachment === null) return null
111
+
112
+ const logId = stringField(event.attachment, 'src_logId')
113
+ const sourceUserId = numericField(event.attachment, 'src_userId')
114
+ if (logId === null || sourceUserId === null) return null
115
+
116
+ const sourceAuthorId = String(sourceUserId)
117
+ const quotedText = stringField(event.attachment, 'src_message')
118
+ return {
119
+ logId,
120
+ authorId: sourceAuthorId,
121
+ // classifyInbound is synchronous and has no cheap author resolver access;
122
+ // fall back to Kakao's stable source user id rather than fetching.
123
+ authorName: sourceAuthorId,
124
+ quotedText,
125
+ target: sourceAuthorId === selfUserId ? 'bot' : 'other',
126
+ }
127
+ }
128
+
129
+ function referenceContextPayload(
130
+ replyContext: ParsedReplyContext | null,
131
+ ): { referenceContext: InboundReferenceContext } | Record<string, never> {
132
+ if (replyContext === null || replyContext.quotedText === null || replyContext.quotedText.trim() === '') return {}
133
+ return {
134
+ referenceContext: {
135
+ kind: 'reply',
136
+ sources: [
137
+ {
138
+ adapter: 'kakaotalk',
139
+ authorId: replyContext.authorId,
140
+ authorName: replyContext.authorName,
141
+ text: replyContext.quotedText,
142
+ },
143
+ ],
144
+ },
145
+ }
146
+ }
147
+
148
+ function stringField(record: Record<string, unknown>, key: string): string | null {
149
+ const value = record[key]
150
+ return typeof value === 'string' && value.length > 0 ? value : null
151
+ }
152
+
153
+ function numericField(record: Record<string, unknown>, key: string): number | null {
154
+ const value = record[key]
155
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
156
+ }