typeclaw 0.20.0 → 0.21.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.
@@ -1,4 +1,10 @@
1
- import type { ReactionCallback, ReactionErrorCode, ReactionRef, ReactionResult } from '@/channels/types'
1
+ import type {
2
+ RemoveReactionCallback,
3
+ ReactionCallback,
4
+ ReactionErrorCode,
5
+ ReactionRef,
6
+ ReactionResult,
7
+ } from '@/channels/types'
2
8
 
3
9
  import type { GithubAuthContext } from './auth'
4
10
  import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
@@ -21,6 +27,11 @@ export type GithubReactionTarget =
21
27
  | { kind: 'issue-comment'; owner: string; repo: string; commentId: number }
22
28
  | { kind: 'pr-review-comment'; owner: string; repo: string; commentId: number }
23
29
 
30
+ export type GithubReactionRemovalTarget =
31
+ | { kind: 'issue'; owner: string; repo: string; issueNumber: number; reactionId: number }
32
+ | { kind: 'issue-comment'; owner: string; repo: string; commentId: number; reactionId: number }
33
+ | { kind: 'pr-review-comment'; owner: string; repo: string; commentId: number; reactionId: number }
34
+
24
35
  export function encodeGithubReactionRef(target: GithubReactionTarget): ReactionRef {
25
36
  return { adapter: 'github', value: JSON.stringify(target) }
26
37
  }
@@ -35,6 +46,7 @@ export function decodeGithubReactionRef(ref: ReactionRef): GithubReactionTarget
35
46
  }
36
47
  if (typeof parsed !== 'object' || parsed === null) return null
37
48
  const t = parsed as Record<string, unknown>
49
+ if (t.op !== undefined) return null
38
50
  const owner = typeof t.owner === 'string' ? t.owner : null
39
51
  const repo = typeof t.repo === 'string' ? t.repo : null
40
52
  if (owner === null || repo === null) return null
@@ -47,6 +59,32 @@ export function decodeGithubReactionRef(ref: ReactionRef): GithubReactionTarget
47
59
  return null
48
60
  }
49
61
 
62
+ export function encodeGithubRemovalRef(target: GithubReactionRemovalTarget): ReactionRef {
63
+ return { adapter: 'github', value: JSON.stringify({ op: 'remove', ...target }) }
64
+ }
65
+
66
+ export function decodeGithubRemovalRef(ref: ReactionRef): GithubReactionRemovalTarget | null {
67
+ if (ref.adapter !== 'github') return null
68
+ let parsed: unknown
69
+ try {
70
+ parsed = JSON.parse(ref.value)
71
+ } catch {
72
+ return null
73
+ }
74
+ if (typeof parsed !== 'object' || parsed === null) return null
75
+ const t = parsed as Record<string, unknown>
76
+ const owner = typeof t.owner === 'string' ? t.owner : null
77
+ const repo = typeof t.repo === 'string' ? t.repo : null
78
+ if (t.op !== 'remove' || owner === null || repo === null || typeof t.reactionId !== 'number') return null
79
+ if (t.kind === 'issue' && typeof t.issueNumber === 'number') {
80
+ return { kind: 'issue', owner, repo, issueNumber: t.issueNumber, reactionId: t.reactionId }
81
+ }
82
+ if ((t.kind === 'issue-comment' || t.kind === 'pr-review-comment') && typeof t.commentId === 'number') {
83
+ return { kind: t.kind, owner, repo, commentId: t.commentId, reactionId: t.reactionId }
84
+ }
85
+ return null
86
+ }
87
+
50
88
  // GitHub's Reactions API takes a fixed vocabulary of content strings. Map the
51
89
  // adapter-generic emoji name onto it; anything outside the set is reported as
52
90
  // `unsupported` so the model gets a clear signal rather than a silent 422.
@@ -82,8 +120,35 @@ export function createGithubReactionCallback(deps: {
82
120
  const endpoint = reactionEndpoint(target)
83
121
  const endpointKind: OutboundEndpointKind =
84
122
  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,
123
+ return await postReaction(
124
+ fetchImpl,
125
+ await deps.token({ repoSlug: `${target.owner}/${target.repo}` }),
126
+ endpoint,
127
+ target,
128
+ {
129
+ content,
130
+ authType: deps.authType,
131
+ endpointKind,
132
+ },
133
+ )
134
+ }
135
+ }
136
+
137
+ export function createGithubRemoveReactionCallback(deps: {
138
+ token: (context?: GithubAuthContext) => Promise<string>
139
+ authType: GithubAuthType
140
+ fetchImpl?: typeof fetch
141
+ }): RemoveReactionCallback {
142
+ const fetchImpl = deps.fetchImpl ?? fetch
143
+ return async (req): Promise<ReactionResult> => {
144
+ if (req.adapter !== 'github') return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
145
+ const target = decodeGithubRemovalRef(req.reactionRef)
146
+ if (target === null) return { ok: false, error: 'unparseable github reaction removal ref', code: 'unsupported' }
147
+
148
+ const endpoint = removeReactionEndpoint(target)
149
+ const endpointKind: OutboundEndpointKind =
150
+ target.kind === 'pr-review-comment' ? 'pr-review-comment-reaction' : 'issue-reaction'
151
+ return await deleteReaction(fetchImpl, await deps.token({ repoSlug: `${target.owner}/${target.repo}` }), endpoint, {
87
152
  authType: deps.authType,
88
153
  endpointKind,
89
154
  })
@@ -102,10 +167,23 @@ function reactionEndpoint(target: GithubReactionTarget): string {
102
167
  }
103
168
  }
104
169
 
170
+ function removeReactionEndpoint(target: GithubReactionRemovalTarget): string {
171
+ const base = `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}`
172
+ switch (target.kind) {
173
+ case 'issue':
174
+ return `${base}/issues/${target.issueNumber}/reactions/${target.reactionId}`
175
+ case 'issue-comment':
176
+ return `${base}/issues/comments/${target.commentId}/reactions/${target.reactionId}`
177
+ case 'pr-review-comment':
178
+ return `${base}/pulls/comments/${target.commentId}/reactions/${target.reactionId}`
179
+ }
180
+ }
181
+
105
182
  async function postReaction(
106
183
  fetchImpl: typeof fetch,
107
184
  token: string,
108
185
  url: string,
186
+ target: GithubReactionTarget,
109
187
  options: { content: string; authType: GithubAuthType; endpointKind: OutboundEndpointKind },
110
188
  ): Promise<ReactionResult> {
111
189
  let response: Response
@@ -121,7 +199,11 @@ async function postReaction(
121
199
  // 201 = reaction created, 200 = the actor already left this same reaction.
122
200
  // Both are success: an :eyes: that's already there is the desired end state,
123
201
  // 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 }
202
+ if (response.status === 200 || response.status === 201) {
203
+ const reactionId = await readReactionId(response)
204
+ if (reactionId === null) return { ok: true }
205
+ return { ok: true, reactionRef: encodeGithubRemovalRef(removalTargetFor(target, reactionId)) }
206
+ }
125
207
  const text = await response.text().catch(() => '')
126
208
  const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
127
209
  if (isOutboundPermissionDenial(response.status, text)) {
@@ -134,6 +216,58 @@ async function postReaction(
134
216
  return { ok: false, error: baseError, code: classifyStatus(response.status) }
135
217
  }
136
218
 
219
+ async function deleteReaction(
220
+ fetchImpl: typeof fetch,
221
+ token: string,
222
+ url: string,
223
+ options: { authType: GithubAuthType; endpointKind: OutboundEndpointKind },
224
+ ): Promise<ReactionResult> {
225
+ let response: Response
226
+ try {
227
+ response = await fetchImpl(url, {
228
+ method: 'DELETE',
229
+ headers: githubJsonHeaders(token),
230
+ })
231
+ } catch (err) {
232
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
233
+ }
234
+ if (response.status === 204) return { ok: true }
235
+ const text = await response.text().catch(() => '')
236
+ const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
237
+ if (isOutboundPermissionDenial(response.status, text)) {
238
+ return {
239
+ ok: false,
240
+ error: `${baseError}${buildOutboundPermissionGuidance({ authType: options.authType, endpointKind: options.endpointKind })}`,
241
+ code: 'permission-denied',
242
+ }
243
+ }
244
+ return { ok: false, error: baseError, code: classifyStatus(response.status) }
245
+ }
246
+
247
+ function removalTargetFor(target: GithubReactionTarget, reactionId: number): GithubReactionRemovalTarget {
248
+ switch (target.kind) {
249
+ case 'issue':
250
+ return { kind: 'issue', owner: target.owner, repo: target.repo, issueNumber: target.issueNumber, reactionId }
251
+ case 'issue-comment':
252
+ return { kind: 'issue-comment', owner: target.owner, repo: target.repo, commentId: target.commentId, reactionId }
253
+ case 'pr-review-comment':
254
+ return {
255
+ kind: 'pr-review-comment',
256
+ owner: target.owner,
257
+ repo: target.repo,
258
+ commentId: target.commentId,
259
+ reactionId,
260
+ }
261
+ }
262
+ }
263
+
264
+ async function readReactionId(response: Response): Promise<number | null> {
265
+ const body = await response.json().catch(() => null)
266
+ if (typeof body !== 'object' || body === null) return null
267
+ const id = (body as Record<string, unknown>).id
268
+ return typeof id === 'number' ? id : null
269
+ }
270
+
137
271
  function classifyStatus(status: number): ReactionErrorCode {
138
272
  if (status === 403) return 'permission-denied'
139
273
  if (status === 404) return 'not-found'
@@ -182,7 +182,7 @@ function describeSlackMedia(event: SlackInboundMessageEvent): InboundAttachment[
182
182
  return (event.files ?? []).map((file, index) => describeSlackFile(file, index + 1))
183
183
  }
184
184
 
185
- function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
185
+ export function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
186
186
  return {
187
187
  id,
188
188
  kind: 'file',
@@ -192,7 +192,7 @@ function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
192
192
  }
193
193
  }
194
194
 
195
- function renderPlaceholder(attachment: InboundAttachment): string {
195
+ export function renderPlaceholder(attachment: InboundAttachment): string {
196
196
  const parts: string[] = [`Slack attachment #${attachment.id}: ${attachment.kind}`]
197
197
  if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
198
198
  if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
@@ -1,4 +1,9 @@
1
- import { SlackBotClient, SlackBotListener, type SlackSocketModeSlashCommandArgs } from 'agent-messenger/slackbot'
1
+ import {
2
+ SlackBotClient,
3
+ SlackBotListener,
4
+ type SlackFile,
5
+ type SlackSocketModeSlashCommandArgs,
6
+ } from 'agent-messenger/slackbot'
2
7
 
3
8
  import {
4
9
  MEMBERSHIP_ENUMERATION_CAP,
@@ -28,7 +33,9 @@ import { createSlackAuthorResolver } from './slack-bot-author-resolver'
28
33
  import { createSlackChannelResolver } from './slack-bot-channel-resolver'
29
34
  import {
30
35
  classifyInbound,
36
+ describeSlackFile,
31
37
  type InboundDropReason,
38
+ renderPlaceholder,
32
39
  type SlackInboundAppMentionEvent,
33
40
  type SlackInboundMessageEvent,
34
41
  } from './slack-bot-classify'
@@ -369,6 +376,7 @@ type SlackRawHistoryMessage = {
369
376
  text?: string
370
377
  thread_ts?: string
371
378
  parent_user_id?: string
379
+ files?: SlackFile[]
372
380
  }
373
381
 
374
382
  type SlackHistoryResponse = {
@@ -601,14 +609,29 @@ function mapSlackMessage(msg: SlackRawHistoryMessage, botUserId: string | null):
601
609
  msg.parent_user_id === botUserId
602
610
  ? msg.thread_ts
603
611
  : null
612
+ // The history fetch bypasses the inbound classifier, so files on
613
+ // already-posted messages (e.g. an image on a thread root the agent is
614
+ // later @-mentioned under) must be mapped here too — otherwise they are
615
+ // silently dropped and look_at_channel_attachment can never resolve them.
616
+ // Mirror splitInbound: bake placeholders into text and carry the structured
617
+ // attachments so the router can resolve ids.
618
+ const attachments = (msg.files ?? []).map((file, index) => describeSlackFile(file, index + 1))
619
+ const rawText = msg.text ?? ''
620
+ const text =
621
+ attachments.length === 0
622
+ ? rawText
623
+ : rawText === ''
624
+ ? attachments.map(renderPlaceholder).join('\n')
625
+ : `${rawText}\n${attachments.map(renderPlaceholder).join('\n')}`
604
626
  return {
605
627
  externalMessageId: msg.ts,
606
628
  authorId: msg.user ?? msg.bot_id ?? 'unknown',
607
629
  authorName: msg.user ?? msg.bot_id ?? 'unknown',
608
- text: msg.text ?? '',
630
+ text,
609
631
  ts: slackTsToMillis(msg.ts),
610
632
  isBot,
611
633
  replyToBotMessageId,
634
+ ...(attachments.length > 0 ? { attachments } : {}),
612
635
  }
613
636
  }
614
637
 
@@ -89,14 +89,51 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
89
89
  if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
90
90
  if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
91
91
 
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
92
+ // Multi-human pre-sticky target check. In a busy group the conversational
93
+ // target shifts every message: the author we're mid-exchange with (and hold
94
+ // a sticky credit for) may, on THIS turn, structurally address a third party
95
+ // "@bob what do you think?", a reply to another human's message, or a
96
+ // peer bot by name. Sticky below is content-blind by design (it answers "am
97
+ // I mid-conversation with this author?"), so without this guard it would
98
+ // force-engage on a message plainly aimed elsewhere and burn the credit.
99
+ //
100
+ // This stays inside the pinned content-blind philosophy: it adds NO semantic
101
+ // text interpretation. It reuses the SAME structural booleans the post-alias
102
+ // suppressors already trust (`mentionsOthers`, `replyToOtherMessageId`,
103
+ // `textTargetsAnyPeerBot`) — the adapter classifiers decide "addressed to
104
+ // someone else", not this gate. The only refinement is ordering: when those
105
+ // structural signals fire in a multi-human group, observe BEFORE consuming
106
+ // sticky, and PRESERVE the credit so the author's next untargeted follow-up
107
+ // still wakes us within the window. A plain follow-up (no suppressor set)
108
+ // is untouched: it falls through to sticky and engages exactly as before.
109
+ //
110
+ // Solo-human channels are deliberately excluded (`effectiveHumans > 1`):
111
+ // there the sticky-over-mentionsOthers behavior is intentional and tested.
112
+ // The `!matchesAnyAlias` guard preserves the ladder invariant "explicit
113
+ // address to us beats structural targeting of others": a message that names
114
+ // us by alias engages on the alias rule below even when it ALSO tags a third
115
+ // party (the "봉봉아 펭펭아 둘 다 봐" multi-bot case), so we must not pre-empt
116
+ // it here. We only step aside for a credited author whose message is aimed
117
+ // PURELY elsewhere.
118
+ if (
119
+ effectiveHumans > 1 &&
120
+ config.stickiness !== 'off' &&
121
+ !matchesAnyAlias(message.text, selfAliases) &&
122
+ targetsSomeoneElse(message, participants, botInThread)
123
+ ) {
124
+ return 'observe'
125
+ }
126
+
127
+ // Sticky credit force-engages for the full window. This gate is deliberately
128
+ // content-blind: it answers "am I in an active conversation with this
129
+ // author?", not "does THIS message need a reply?" — a boolean over
130
+ // membership cannot tell "where did you send it?" (reply) from "lol ok"
131
+ // (chatter). Selectivity for plain follow-ups is the MODEL's job: engaged
97
132
  // group turns get a `composeTurnPrompt` nudge (keyed off `isMultiHumanGroup`)
98
133
  // to answer real follow-ups and `NO_REPLY` chatter. Gating sticky off in
99
- // groups instead (the prior approach) dropped genuine follow-ups outright.
134
+ // groups wholesale (the prior approach) dropped genuine follow-ups outright;
135
+ // the pre-check above is narrower — it only steps aside when the message is
136
+ // STRUCTURALLY addressed elsewhere, leaving plain follow-ups engaged.
100
137
  if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
101
138
  return 'engage'
102
139
  }
@@ -155,37 +192,40 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
155
192
  // has rejected that design repeatedly. The right knob is `trigger`
156
193
  // (which already applies symmetrically to humans and bots) plus this
157
194
  // fallback fix.
158
- if (message.mentionsOthers) return 'observe'
159
- // The replyToOtherMessageId suppressor exists to keep the bot out of
160
- // human-to-human side conversations in busy channels. But Slack's
161
- // `parent_user_id` is the THREAD ROOT author, not the immediate parent
162
- // author — so a thread the human starts by @-mentioning the bot
163
- // produces `replyToOtherMessageId` on every follow-up (root author is
164
- // the human, not us), which would silently drop every reply after the
165
- // first. Once the bot has actually sent into this thread, subsequent
166
- // replies are part of OUR conversation regardless of who started it,
167
- // so the suppressor stops applying. The two-humans-in-a-thread case
168
- // PR #58 fixed is preserved because the bot never sent into that
169
- // thread in the first place.
170
- if (message.replyToOtherMessageId !== null && !botInThread) return 'observe'
171
-
172
- // Plain-text peer-bot addressing as a fallback suppressor. We've reached
173
- // here because the message lacks a structural mention/reply/dm AND
174
- // doesn't contain our own alias. If it DOES contain a known peer bot's
175
- // observed display name, the solo-human fallback would still engage us
176
- // — same wrong behavior the alias trigger is meant to fix, just for
177
- // peers instead of self. Each bot only configures its own aliases, so
178
- // the only source of peer names is `participants[]` (observed
179
- // authorName once a peer has spoken at least once in this channel).
180
- // First-time addressing of a never-seen peer slips through; after that
181
- // peer's first message it's caught forever.
182
- if (textTargetsAnyPeerBot(message.text, participants)) return 'observe'
195
+ if (targetsSomeoneElse(message, participants, botInThread)) return 'observe'
183
196
 
184
197
  if (effectiveHumans <= 1 && !message.authorIsBot) return 'engage'
185
198
 
186
199
  return 'observe'
187
200
  }
188
201
 
202
+ // Structural "this message is addressed to someone other than us" test. Pure
203
+ // over adapter-classified booleans + observed peer names — no semantic text
204
+ // interpretation, so it is safe for the content-blind engagement gate. Shared
205
+ // by the multi-human pre-sticky check and the post-alias fallback suppressors
206
+ // so the two can never drift apart.
207
+ //
208
+ // - `mentionsOthers` — the message tags at least one other user and none of
209
+ // the mentions resolve to us.
210
+ // - `replyToOtherMessageId !== null && !botInThread` — the message replies to
211
+ // a non-bot message AND we haven't sent into this thread yet. Slack's
212
+ // `parent_user_id` is the THREAD ROOT author, not the immediate parent, so
213
+ // a thread a human opens by @-mentioning us would otherwise drop every
214
+ // follow-up; `botInThread` is the escape hatch once we're participating.
215
+ // - `textTargetsAnyPeerBot` — the text names a known peer bot (observed via
216
+ // `participants[]`). A never-seen peer's first addressing slips through,
217
+ // then is caught forever once that peer has spoken once.
218
+ function targetsSomeoneElse(
219
+ message: InboundMessage,
220
+ participants: readonly ChannelParticipant[],
221
+ botInThread: boolean,
222
+ ): boolean {
223
+ if (message.mentionsOthers) return true
224
+ if (message.replyToOtherMessageId !== null && !botInThread) return true
225
+ if (textTargetsAnyPeerBot(message.text, participants)) return true
226
+ return false
227
+ }
228
+
189
229
  export function countEffectiveHumans(
190
230
  participants: readonly ChannelParticipant[],
191
231
  membership: MembershipCount | null,