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.
- package/package.json +1 -1
- package/src/agent/index.ts +7 -0
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +265 -22
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +79 -0
- package/src/channels/adapters/github/index.ts +19 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +276 -0
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +25 -2
- package/src/channels/engagement.ts +81 -44
- package/src/channels/router.ts +255 -18
- package/src/channels/types.ts +57 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +147 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/inspect.ts +3 -0
- package/src/dreams/git.ts +85 -0
- package/src/dreams/index.ts +134 -0
- package/src/dreams/parse.ts +224 -0
- package/src/dreams/render.ts +155 -0
- package/src/dreams/types.ts +50 -0
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/server/index.ts +49 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
- 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 {
|
|
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
|
|
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
|
-
//
|
|
85
|
-
// solo
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// credit),
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
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
|
|
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
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
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
|
}
|