typeclaw 0.19.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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +7 -0
  3. package/src/agent/live-subagents.ts +4 -0
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/session-origin.ts +32 -10
  6. package/src/agent/tools/channel-react.ts +79 -0
  7. package/src/agent/tools/restart.ts +23 -52
  8. package/src/agent/tools/spawn-subagent.ts +1 -0
  9. package/src/agent/tools/subagent-access.ts +67 -0
  10. package/src/agent/tools/subagent-cancel.ts +11 -6
  11. package/src/agent/tools/subagent-output.ts +10 -2
  12. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  13. package/src/channels/adapters/discord-bot.ts +265 -22
  14. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  15. package/src/channels/adapters/github/inbound.ts +79 -0
  16. package/src/channels/adapters/github/index.ts +19 -0
  17. package/src/channels/adapters/github/permission-guidance.ts +20 -1
  18. package/src/channels/adapters/github/reactions.ts +276 -0
  19. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  20. package/src/channels/adapters/slack-bot.ts +25 -2
  21. package/src/channels/engagement.ts +81 -44
  22. package/src/channels/router.ts +255 -18
  23. package/src/channels/types.ts +57 -0
  24. package/src/cli/builtins.ts +1 -0
  25. package/src/cli/dreams.ts +147 -0
  26. package/src/cli/index.ts +1 -0
  27. package/src/cli/inspect.ts +3 -0
  28. package/src/dreams/git.ts +85 -0
  29. package/src/dreams/index.ts +134 -0
  30. package/src/dreams/parse.ts +224 -0
  31. package/src/dreams/render.ts +155 -0
  32. package/src/dreams/types.ts +50 -0
  33. package/src/inspect/loop.ts +12 -1
  34. package/src/permissions/permissions.ts +24 -0
  35. package/src/server/index.ts +49 -0
  36. package/src/shared/protocol.ts +2 -0
  37. package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
  38. package/src/tui/index.ts +70 -18
@@ -0,0 +1,276 @@
1
+ import type {
2
+ RemoveReactionCallback,
3
+ ReactionCallback,
4
+ ReactionErrorCode,
5
+ ReactionRef,
6
+ ReactionResult,
7
+ } from '@/channels/types'
8
+
9
+ import type { GithubAuthContext } from './auth'
10
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
11
+ import {
12
+ buildOutboundPermissionGuidance,
13
+ type GithubAuthType,
14
+ isOutboundPermissionDenial,
15
+ type OutboundEndpointKind,
16
+ } from './permission-guidance'
17
+
18
+ // The reactable target, distinguished by the webhook event the inbound came
19
+ // from. The router collapses every github inbound to the same `chat`/
20
+ // `externalMessageId` pair, so this kind — known only at classification time —
21
+ // is what selects the right Reactions endpoint. `issue` covers both issue and
22
+ // PR bodies (GitHub models a PR body as an issue for reactions); `discussion`
23
+ // is unsupported until the GraphQL `addReaction` path lands, so the classifier
24
+ // does not stamp it today.
25
+ export type GithubReactionTarget =
26
+ | { kind: 'issue'; owner: string; repo: string; issueNumber: number }
27
+ | { kind: 'issue-comment'; owner: string; repo: string; commentId: number }
28
+ | { kind: 'pr-review-comment'; owner: string; repo: string; commentId: number }
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
+
35
+ export function encodeGithubReactionRef(target: GithubReactionTarget): ReactionRef {
36
+ return { adapter: 'github', value: JSON.stringify(target) }
37
+ }
38
+
39
+ export function decodeGithubReactionRef(ref: ReactionRef): GithubReactionTarget | null {
40
+ if (ref.adapter !== 'github') return null
41
+ let parsed: unknown
42
+ try {
43
+ parsed = JSON.parse(ref.value)
44
+ } catch {
45
+ return null
46
+ }
47
+ if (typeof parsed !== 'object' || parsed === null) return null
48
+ const t = parsed as Record<string, unknown>
49
+ if (t.op !== undefined) return null
50
+ const owner = typeof t.owner === 'string' ? t.owner : null
51
+ const repo = typeof t.repo === 'string' ? t.repo : null
52
+ if (owner === null || repo === null) return null
53
+ if (t.kind === 'issue' && typeof t.issueNumber === 'number') {
54
+ return { kind: 'issue', owner, repo, issueNumber: t.issueNumber }
55
+ }
56
+ if ((t.kind === 'issue-comment' || t.kind === 'pr-review-comment') && typeof t.commentId === 'number') {
57
+ return { kind: t.kind, owner, repo, commentId: t.commentId }
58
+ }
59
+ return null
60
+ }
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
+
88
+ // GitHub's Reactions API takes a fixed vocabulary of content strings. Map the
89
+ // adapter-generic emoji name onto it; anything outside the set is reported as
90
+ // `unsupported` so the model gets a clear signal rather than a silent 422.
91
+ const REACTION_CONTENT: Record<string, string> = {
92
+ eyes: 'eyes',
93
+ '+1': '+1',
94
+ thumbsup: '+1',
95
+ '-1': '-1',
96
+ thumbsdown: '-1',
97
+ laugh: 'laugh',
98
+ hooray: 'hooray',
99
+ tada: 'hooray',
100
+ confused: 'confused',
101
+ heart: 'heart',
102
+ rocket: 'rocket',
103
+ }
104
+
105
+ export function createGithubReactionCallback(deps: {
106
+ token: (context?: GithubAuthContext) => Promise<string>
107
+ authType: GithubAuthType
108
+ fetchImpl?: typeof fetch
109
+ }): ReactionCallback {
110
+ const fetchImpl = deps.fetchImpl ?? fetch
111
+ return async (req): Promise<ReactionResult> => {
112
+ if (req.adapter !== 'github') return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
113
+ const content = REACTION_CONTENT[req.emoji.replace(/^:|:$/g, '')]
114
+ if (content === undefined) {
115
+ return { ok: false, error: `github does not support reaction "${req.emoji}"`, code: 'unsupported' }
116
+ }
117
+ const target = decodeGithubReactionRef(req.reactionRef)
118
+ if (target === null) return { ok: false, error: 'unparseable github reaction ref', code: 'unsupported' }
119
+
120
+ const endpoint = reactionEndpoint(target)
121
+ const endpointKind: OutboundEndpointKind =
122
+ target.kind === 'pr-review-comment' ? 'pr-review-comment-reaction' : 'issue-reaction'
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, {
152
+ authType: deps.authType,
153
+ endpointKind,
154
+ })
155
+ }
156
+ }
157
+
158
+ function reactionEndpoint(target: GithubReactionTarget): string {
159
+ const base = `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}`
160
+ switch (target.kind) {
161
+ case 'issue':
162
+ return `${base}/issues/${target.issueNumber}/reactions`
163
+ case 'issue-comment':
164
+ return `${base}/issues/comments/${target.commentId}/reactions`
165
+ case 'pr-review-comment':
166
+ return `${base}/pulls/comments/${target.commentId}/reactions`
167
+ }
168
+ }
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
+
182
+ async function postReaction(
183
+ fetchImpl: typeof fetch,
184
+ token: string,
185
+ url: string,
186
+ target: GithubReactionTarget,
187
+ options: { content: string; authType: GithubAuthType; endpointKind: OutboundEndpointKind },
188
+ ): Promise<ReactionResult> {
189
+ let response: Response
190
+ try {
191
+ response = await fetchImpl(url, {
192
+ method: 'POST',
193
+ headers: githubJsonHeaders(token),
194
+ body: JSON.stringify({ content: options.content }),
195
+ })
196
+ } catch (err) {
197
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
198
+ }
199
+ // 201 = reaction created, 200 = the actor already left this same reaction.
200
+ // Both are success: an :eyes: that's already there is the desired end state,
201
+ // so a duplicate webhook delivery (or a retried engage) must not surface an error.
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
+ }
207
+ const text = await response.text().catch(() => '')
208
+ const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
209
+ if (isOutboundPermissionDenial(response.status, text)) {
210
+ return {
211
+ ok: false,
212
+ error: `${baseError}${buildOutboundPermissionGuidance({ authType: options.authType, endpointKind: options.endpointKind })}`,
213
+ code: 'permission-denied',
214
+ }
215
+ }
216
+ return { ok: false, error: baseError, code: classifyStatus(response.status) }
217
+ }
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
+
271
+ function classifyStatus(status: number): ReactionErrorCode {
272
+ if (status === 403) return 'permission-denied'
273
+ if (status === 404) return 'not-found'
274
+ if (status === 429) return 'rate-limited'
275
+ return 'transient'
276
+ }
@@ -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
 
@@ -81,25 +81,60 @@ 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
+ // 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
132
+ // group turns get a `composeTurnPrompt` nudge (keyed off `isMultiHumanGroup`)
133
+ // to answer real follow-ups and `NO_REPLY` chatter. Gating sticky off in
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.
137
+ if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
103
138
  return 'engage'
104
139
  }
105
140
 
@@ -157,37 +192,40 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
157
192
  // has rejected that design repeatedly. The right knob is `trigger`
158
193
  // (which already applies symmetrically to humans and bots) plus this
159
194
  // fallback fix.
160
- if (message.mentionsOthers) return 'observe'
161
- // The replyToOtherMessageId suppressor exists to keep the bot out of
162
- // human-to-human side conversations in busy channels. But Slack's
163
- // `parent_user_id` is the THREAD ROOT author, not the immediate parent
164
- // author — so a thread the human starts by @-mentioning the bot
165
- // produces `replyToOtherMessageId` on every follow-up (root author is
166
- // the human, not us), which would silently drop every reply after the
167
- // first. Once the bot has actually sent into this thread, subsequent
168
- // replies are part of OUR conversation regardless of who started it,
169
- // so the suppressor stops applying. The two-humans-in-a-thread case
170
- // PR #58 fixed is preserved because the bot never sent into that
171
- // thread in the first place.
172
- if (message.replyToOtherMessageId !== null && !botInThread) return 'observe'
173
-
174
- // Plain-text peer-bot addressing as a fallback suppressor. We've reached
175
- // here because the message lacks a structural mention/reply/dm AND
176
- // doesn't contain our own alias. If it DOES contain a known peer bot's
177
- // observed display name, the solo-human fallback would still engage us
178
- // — same wrong behavior the alias trigger is meant to fix, just for
179
- // peers instead of self. Each bot only configures its own aliases, so
180
- // the only source of peer names is `participants[]` (observed
181
- // authorName once a peer has spoken at least once in this channel).
182
- // First-time addressing of a never-seen peer slips through; after that
183
- // peer's first message it's caught forever.
184
- if (textTargetsAnyPeerBot(message.text, participants)) return 'observe'
195
+ if (targetsSomeoneElse(message, participants, botInThread)) return 'observe'
185
196
 
186
197
  if (effectiveHumans <= 1 && !message.authorIsBot) return 'engage'
187
198
 
188
199
  return 'observe'
189
200
  }
190
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
+
191
229
  export function countEffectiveHumans(
192
230
  participants: readonly ChannelParticipant[],
193
231
  membership: MembershipCount | null,
@@ -197,12 +235,11 @@ export function countEffectiveHumans(
197
235
  return resolveEffectiveHumans(persistedHumans, membership, now)
198
236
  }
199
237
 
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.
238
+ // A multi-human group is where the prompt's default "answer everything"
239
+ // eagerness needs tempering. The router reads this in `route()` to decide
240
+ // whether to append the group-chat nudge that tells the model to be selective
241
+ // (answer real follow-ups, `NO_REPLY` chatter) on its engaged turns. DMs and
242
+ // solo-human channels skip the nudge there, replying to everything is right.
206
243
  export function isMultiHumanGroup(isDm: boolean, effectiveHumans: number): boolean {
207
244
  return !isDm && effectiveHumans > 1
208
245
  }