typeclaw 0.19.0 → 0.20.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.
@@ -4,6 +4,7 @@ import type { InboundMessage } from '@/channels/types'
4
4
 
5
5
  import type { DeliveryDedup } from './dedup'
6
6
  import { isGithubEventAllowed } from './event-allowlist'
7
+ import { encodeGithubReactionRef, type GithubReactionTarget } from './reactions'
7
8
 
8
9
  export type GithubInboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
9
10
 
@@ -109,6 +110,7 @@ export function classifyGithubInbound(
109
110
  user,
110
111
  selfLogin,
111
112
  comment.created_at,
113
+ { kind: 'issue-comment', owner: repository.owner, repo: repository.name, commentId: id },
112
114
  )
113
115
  }
114
116
 
@@ -127,6 +129,7 @@ export function classifyGithubInbound(
127
129
  readUser(comment.user),
128
130
  selfLogin,
129
131
  comment.created_at,
132
+ { kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
130
133
  )
131
134
  }
132
135
 
@@ -144,6 +147,7 @@ export function classifyGithubInbound(
144
147
  readUser(comment.user),
145
148
  selfLogin,
146
149
  comment.created_at,
150
+ null,
147
151
  )
148
152
  }
149
153
 
@@ -160,6 +164,7 @@ export function classifyGithubInbound(
160
164
  readUser(issue.user),
161
165
  selfLogin,
162
166
  issue.created_at,
167
+ { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
163
168
  )
164
169
  }
165
170
 
@@ -189,6 +194,7 @@ export function classifyGithubInbound(
189
194
  readUser(pr.user),
190
195
  selfLogin,
191
196
  pr.created_at,
197
+ { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
192
198
  )
193
199
  }
194
200
 
@@ -206,6 +212,7 @@ export function classifyGithubInbound(
206
212
  readUser(review.user),
207
213
  selfLogin,
208
214
  review.submitted_at,
215
+ null,
209
216
  )
210
217
  }
211
218
 
@@ -222,6 +229,7 @@ export function classifyGithubInbound(
222
229
  readUser(discussion.user),
223
230
  selfLogin,
224
231
  discussion.created_at,
232
+ null,
225
233
  )
226
234
  }
227
235
 
@@ -340,6 +348,7 @@ function buildInbound(
340
348
  user: GithubUser | null,
341
349
  selfLogin: string | null,
342
350
  rawTs: unknown,
351
+ reactionTarget: GithubReactionTarget | null,
343
352
  ): InboundMessage | null {
344
353
  if (user === null) return null
345
354
  const text = typeof rawText === 'string' ? rawText : ''
@@ -347,6 +356,7 @@ function buildInbound(
347
356
  ...key,
348
357
  text,
349
358
  externalMessageId: String(id),
359
+ ...(reactionTarget !== null ? { reactionRef: encodeGithubReactionRef(reactionTarget) } : {}),
350
360
  authorId: String(user.id),
351
361
  authorName: user.login,
352
362
  authorIsBot: user.type === 'Bot',
@@ -19,6 +19,7 @@ import {
19
19
  buildPermissionGuidance,
20
20
  parseListHooksPermissionStatus,
21
21
  } from './permission-guidance'
22
+ import { createGithubReactionCallback } from './reactions'
22
23
  import { createTeamMembershipChecker } from './team-membership'
23
24
  import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
24
25
 
@@ -119,6 +120,11 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
119
120
  logger,
120
121
  fetchImpl,
121
122
  })
123
+ const reaction = createGithubReactionCallback({
124
+ token: authToken,
125
+ authType: options.secrets.auth.type,
126
+ fetchImpl,
127
+ })
122
128
  const history = createGithubHistoryCallback({
123
129
  token: authToken,
124
130
  fetchImpl,
@@ -161,6 +167,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
161
167
  // Register all callbacks before binding the HTTP listener so the router
162
168
  // is fully wired before any webhook can arrive.
163
169
  options.router.registerOutbound('github', outbound)
170
+ options.router.registerReaction('github', reaction)
164
171
  options.router.registerTyping('github', typing)
165
172
  options.router.registerHistory('github', history)
166
173
  options.router.registerMembership('github', membership)
@@ -172,6 +179,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
172
179
  // Listener failed — roll back all registrations so stop() is a no-op
173
180
  // and the manager can report the failure cleanly.
174
181
  options.router.unregisterOutbound('github', outbound)
182
+ options.router.unregisterReaction('github', reaction)
175
183
  options.router.unregisterTyping('github', typing)
176
184
  options.router.unregisterHistory('github', history)
177
185
  options.router.unregisterMembership('github', membership)
@@ -292,6 +300,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
292
300
  if (!started) return
293
301
  started = false
294
302
  options.router.unregisterOutbound('github', outbound)
303
+ options.router.unregisterReaction('github', reaction)
295
304
  options.router.unregisterTyping('github', typing)
296
305
  options.router.unregisterHistory('github', history)
297
306
  options.router.unregisterMembership('github', membership)
@@ -6,7 +6,12 @@ export type GithubAuthType = 'pat' | 'app'
6
6
  // permission-failure response. Each value maps to a distinct GitHub App
7
7
  // permission family (and, for PATs, a distinct scope), so each surfaces a
8
8
  // different remediation message.
9
- export type OutboundEndpointKind = 'issue-comment' | 'pr-review-reply' | 'discussion-comment'
9
+ export type OutboundEndpointKind =
10
+ | 'issue-comment'
11
+ | 'pr-review-reply'
12
+ | 'discussion-comment'
13
+ | 'issue-reaction'
14
+ | 'pr-review-comment-reaction'
10
15
 
11
16
  // Parses webhook-register errors of the shape `list hooks failed: <status> <body>`.
12
17
  // Returns the status code when it matches the two shapes GitHub emits for
@@ -127,6 +132,20 @@ const OUTBOUND_PERMISSION_FOR_KIND: Record<
127
132
  patScope: 'repo',
128
133
  patFineGrained: 'Discussions',
129
134
  },
135
+ // Reactions on an issue/PR body or an issue comment go through the Issues
136
+ // permission family; reactions on a PR review comment go through Pull requests.
137
+ 'issue-reaction': {
138
+ label: 'Issues',
139
+ level: 'Read and write',
140
+ patScope: 'repo (or public_repo for public repos)',
141
+ patFineGrained: 'Issues',
142
+ },
143
+ 'pr-review-comment-reaction': {
144
+ label: 'Pull requests',
145
+ level: 'Read and write',
146
+ patScope: 'repo',
147
+ patFineGrained: 'Pull requests',
148
+ },
130
149
  }
131
150
 
132
151
  // Decorate an outbound-API failure with the precise github.com permission a
@@ -0,0 +1,142 @@
1
+ import type { ReactionCallback, ReactionErrorCode, ReactionRef, ReactionResult } from '@/channels/types'
2
+
3
+ import type { GithubAuthContext } from './auth'
4
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
5
+ import {
6
+ buildOutboundPermissionGuidance,
7
+ type GithubAuthType,
8
+ isOutboundPermissionDenial,
9
+ type OutboundEndpointKind,
10
+ } from './permission-guidance'
11
+
12
+ // The reactable target, distinguished by the webhook event the inbound came
13
+ // from. The router collapses every github inbound to the same `chat`/
14
+ // `externalMessageId` pair, so this kind — known only at classification time —
15
+ // is what selects the right Reactions endpoint. `issue` covers both issue and
16
+ // PR bodies (GitHub models a PR body as an issue for reactions); `discussion`
17
+ // is unsupported until the GraphQL `addReaction` path lands, so the classifier
18
+ // does not stamp it today.
19
+ export type GithubReactionTarget =
20
+ | { kind: 'issue'; owner: string; repo: string; issueNumber: number }
21
+ | { kind: 'issue-comment'; owner: string; repo: string; commentId: number }
22
+ | { kind: 'pr-review-comment'; owner: string; repo: string; commentId: number }
23
+
24
+ export function encodeGithubReactionRef(target: GithubReactionTarget): ReactionRef {
25
+ return { adapter: 'github', value: JSON.stringify(target) }
26
+ }
27
+
28
+ export function decodeGithubReactionRef(ref: ReactionRef): GithubReactionTarget | null {
29
+ if (ref.adapter !== 'github') return null
30
+ let parsed: unknown
31
+ try {
32
+ parsed = JSON.parse(ref.value)
33
+ } catch {
34
+ return null
35
+ }
36
+ if (typeof parsed !== 'object' || parsed === null) return null
37
+ const t = parsed as Record<string, unknown>
38
+ const owner = typeof t.owner === 'string' ? t.owner : null
39
+ const repo = typeof t.repo === 'string' ? t.repo : null
40
+ if (owner === null || repo === null) return null
41
+ if (t.kind === 'issue' && typeof t.issueNumber === 'number') {
42
+ return { kind: 'issue', owner, repo, issueNumber: t.issueNumber }
43
+ }
44
+ if ((t.kind === 'issue-comment' || t.kind === 'pr-review-comment') && typeof t.commentId === 'number') {
45
+ return { kind: t.kind, owner, repo, commentId: t.commentId }
46
+ }
47
+ return null
48
+ }
49
+
50
+ // GitHub's Reactions API takes a fixed vocabulary of content strings. Map the
51
+ // adapter-generic emoji name onto it; anything outside the set is reported as
52
+ // `unsupported` so the model gets a clear signal rather than a silent 422.
53
+ const REACTION_CONTENT: Record<string, string> = {
54
+ eyes: 'eyes',
55
+ '+1': '+1',
56
+ thumbsup: '+1',
57
+ '-1': '-1',
58
+ thumbsdown: '-1',
59
+ laugh: 'laugh',
60
+ hooray: 'hooray',
61
+ tada: 'hooray',
62
+ confused: 'confused',
63
+ heart: 'heart',
64
+ rocket: 'rocket',
65
+ }
66
+
67
+ export function createGithubReactionCallback(deps: {
68
+ token: (context?: GithubAuthContext) => Promise<string>
69
+ authType: GithubAuthType
70
+ fetchImpl?: typeof fetch
71
+ }): ReactionCallback {
72
+ const fetchImpl = deps.fetchImpl ?? fetch
73
+ return async (req): Promise<ReactionResult> => {
74
+ if (req.adapter !== 'github') return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
75
+ const content = REACTION_CONTENT[req.emoji.replace(/^:|:$/g, '')]
76
+ if (content === undefined) {
77
+ return { ok: false, error: `github does not support reaction "${req.emoji}"`, code: 'unsupported' }
78
+ }
79
+ const target = decodeGithubReactionRef(req.reactionRef)
80
+ if (target === null) return { ok: false, error: 'unparseable github reaction ref', code: 'unsupported' }
81
+
82
+ const endpoint = reactionEndpoint(target)
83
+ const endpointKind: OutboundEndpointKind =
84
+ target.kind === 'pr-review-comment' ? 'pr-review-comment-reaction' : 'issue-reaction'
85
+ return await postReaction(fetchImpl, await deps.token({ repoSlug: `${target.owner}/${target.repo}` }), endpoint, {
86
+ content,
87
+ authType: deps.authType,
88
+ endpointKind,
89
+ })
90
+ }
91
+ }
92
+
93
+ function reactionEndpoint(target: GithubReactionTarget): string {
94
+ const base = `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}`
95
+ switch (target.kind) {
96
+ case 'issue':
97
+ return `${base}/issues/${target.issueNumber}/reactions`
98
+ case 'issue-comment':
99
+ return `${base}/issues/comments/${target.commentId}/reactions`
100
+ case 'pr-review-comment':
101
+ return `${base}/pulls/comments/${target.commentId}/reactions`
102
+ }
103
+ }
104
+
105
+ async function postReaction(
106
+ fetchImpl: typeof fetch,
107
+ token: string,
108
+ url: string,
109
+ options: { content: string; authType: GithubAuthType; endpointKind: OutboundEndpointKind },
110
+ ): Promise<ReactionResult> {
111
+ let response: Response
112
+ try {
113
+ response = await fetchImpl(url, {
114
+ method: 'POST',
115
+ headers: githubJsonHeaders(token),
116
+ body: JSON.stringify({ content: options.content }),
117
+ })
118
+ } catch (err) {
119
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
120
+ }
121
+ // 201 = reaction created, 200 = the actor already left this same reaction.
122
+ // Both are success: an :eyes: that's already there is the desired end state,
123
+ // so a duplicate webhook delivery (or a retried engage) must not surface an error.
124
+ if (response.status === 200 || response.status === 201) return { ok: true }
125
+ const text = await response.text().catch(() => '')
126
+ const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
127
+ if (isOutboundPermissionDenial(response.status, text)) {
128
+ return {
129
+ ok: false,
130
+ error: `${baseError}${buildOutboundPermissionGuidance({ authType: options.authType, endpointKind: options.endpointKind })}`,
131
+ code: 'permission-denied',
132
+ }
133
+ }
134
+ return { ok: false, error: baseError, code: classifyStatus(response.status) }
135
+ }
136
+
137
+ function classifyStatus(status: number): ReactionErrorCode {
138
+ if (status === 403) return 'permission-denied'
139
+ if (status === 404) return 'not-found'
140
+ if (status === 429) return 'rate-limited'
141
+ return 'transient'
142
+ }
@@ -81,25 +81,23 @@ export type EngagementInput = {
81
81
  export function decideEngagement(input: EngagementInput): EngagementDecision {
82
82
  const { message, config, key, ledger, now, participants, selfAliases, botInThread } = input
83
83
 
84
- // The human count drives both the sticky-credit gate (below) and the
85
- // solo-human fallback (bottom). Compute it once, up front. Peer bots are
86
- // excluded — a 1-human-N-bot room is still "solo" for engagement purposes.
84
+ // Peer bots are excluded from the count — a 1-human-N-bot room is still
85
+ // "solo" for the fallback at the bottom.
87
86
  const effectiveHumans = countEffectiveHumans(participants, input.membership, now)
88
- const multiHumanGroup = isMultiHumanGroup(message.isDm, effectiveHumans)
89
87
 
90
88
  if (config.trigger.includes('dm') && message.isDm) return 'engage'
91
89
  if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
92
90
  if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
93
91
 
94
- // Sticky credit. ALWAYS consume when present (the credit stays one-shot,
95
- // so a later membership change can't resurrect stale conversational
96
- // credit), but only let it FORCE engagement outside a multi-human group.
97
- // In a group, sticky alone no longer wakes the bot on every follow-up —
98
- // the author must re-address us (mention/reply/alias) to re-engage. This
99
- // narrows an existing permissive rule in the exact context where it's
100
- // harmful; it is NOT a new bot-specific gate (peer bots and humans are
101
- // treated identically, via `multiHumanGroup`). See engagement.mdx.
102
- if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now) && !multiHumanGroup) {
92
+ // Sticky credit force-engages in EVERY context (groups included) for the
93
+ // full window. This gate is deliberately content-blind: it answers "am I in
94
+ // an active conversation with this author?", not "does THIS message need a
95
+ // reply?" a boolean over membership cannot tell "where did you send it?"
96
+ // (reply) from "lol ok" (chatter). Selectivity is the MODEL's job: engaged
97
+ // group turns get a `composeTurnPrompt` nudge (keyed off `isMultiHumanGroup`)
98
+ // to answer real follow-ups and `NO_REPLY` chatter. Gating sticky off in
99
+ // groups instead (the prior approach) dropped genuine follow-ups outright.
100
+ if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
103
101
  return 'engage'
104
102
  }
105
103
 
@@ -197,12 +195,11 @@ export function countEffectiveHumans(
197
195
  return resolveEffectiveHumans(persistedHumans, membership, now)
198
196
  }
199
197
 
200
- // A multi-human group is the one place where the chatty "reply to every
201
- // follow-up" behavior (sticky credit, and the prompt's default eagerness) is
202
- // wrong. DMs 1:1, or platform group-DMs reached via the `dm` trigger and
203
- // solo-human channels keep the back-and-forth. The router reuses this to
204
- // decide both sticky suppression and the group-chat prompt nudge, so the two
205
- // stay in lockstep off one definition.
198
+ // A multi-human group is where the prompt's default "answer everything"
199
+ // eagerness needs tempering. The router reads this in `route()` to decide
200
+ // whether to append the group-chat nudge that tells the model to be selective
201
+ // (answer real follow-ups, `NO_REPLY` chatter) on its engaged turns. DMs and
202
+ // solo-human channels skip the nudge there, replying to everything is right.
206
203
  export function isMultiHumanGroup(isDm: boolean, effectiveHumans: number): boolean {
207
204
  return !isDm && effectiveHumans > 1
208
205
  }