typeclaw 0.25.0 → 0.27.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/session-origin.ts +36 -5
- package/src/agent/subagent-completion-reminder.ts +16 -1
- package/src/agent/tools/channel-react.ts +11 -4
- package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
- package/src/channels/adapters/discord-bot-classify.ts +3 -0
- package/src/channels/adapters/discord-bot-reactions.ts +164 -0
- package/src/channels/adapters/discord-bot.ts +23 -0
- package/src/channels/adapters/github/inbound.ts +60 -13
- package/src/channels/adapters/github/review-thread-resolver.ts +28 -3
- package/src/channels/adapters/slack-bot-classify.ts +2 -0
- package/src/channels/adapters/slack-bot-reactions.ts +167 -0
- package/src/channels/adapters/slack-bot.ts +24 -0
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +41 -0
- package/src/cli/inspect.ts +216 -36
- package/src/cli/logs.ts +15 -0
- package/src/cli/tui.ts +33 -39
- package/src/compose/logs.ts +1 -1
- package/src/config/config.ts +43 -2
- package/src/container/logs.ts +70 -22
- package/src/init/index.ts +3 -3
- package/src/inspect/index.ts +128 -42
- package/src/inspect/item-list.ts +44 -0
- package/src/inspect/item.ts +17 -0
- package/src/inspect/label.ts +1 -1
- package/src/inspect/logs-item.ts +79 -0
- package/src/inspect/loop.ts +74 -3
- package/src/inspect/open-item.ts +100 -0
- package/src/inspect/preview.ts +106 -0
- package/src/inspect/session-list.ts +15 -3
- package/src/inspect/transcript-view.ts +182 -0
- package/src/inspect/tui-item.ts +97 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
- package/src/tui/index.ts +72 -32
- package/typeclaw.schema.json +1 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { SlackBotClient } from 'agent-messenger/slackbot'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ReactionCallback,
|
|
5
|
+
ReactionErrorCode,
|
|
6
|
+
ReactionRef,
|
|
7
|
+
ReactionResult,
|
|
8
|
+
RemoveReactionCallback,
|
|
9
|
+
} from '@/channels/types'
|
|
10
|
+
|
|
11
|
+
// The reactable target on Slack: a message is addressed by its channel id plus
|
|
12
|
+
// the message `ts`. The classifier stamps this because `ts` is the inbound's
|
|
13
|
+
// own message timestamp — the same value that becomes `externalMessageId`
|
|
14
|
+
// downstream, but kept in an opaque ref so the router/tool never have to know
|
|
15
|
+
// Slack's addressing. Mirrors the GitHub `GithubReactionTarget` precedent.
|
|
16
|
+
export type SlackReactionTarget = { channel: string; ts: string }
|
|
17
|
+
|
|
18
|
+
// Removal needs the emoji name too: Slack's `reactions.remove` is keyed by
|
|
19
|
+
// (channel, ts, name), unlike GitHub's per-reaction id. We fold the emoji that
|
|
20
|
+
// was added into the removal ref so `RemoveReactionCallback` can reconstruct
|
|
21
|
+
// the exact call without the caller tracking it.
|
|
22
|
+
export type SlackReactionRemovalTarget = { channel: string; ts: string; emoji: string }
|
|
23
|
+
|
|
24
|
+
export function encodeSlackReactionRef(target: SlackReactionTarget): ReactionRef {
|
|
25
|
+
return { adapter: 'slack-bot', value: JSON.stringify(target) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function decodeSlackReactionRef(ref: ReactionRef): SlackReactionTarget | null {
|
|
29
|
+
if (ref.adapter !== 'slack-bot') return null
|
|
30
|
+
const parsed = parseRecord(ref.value)
|
|
31
|
+
if (parsed === null) return null
|
|
32
|
+
if (parsed.op !== undefined) return null
|
|
33
|
+
const channel = typeof parsed.channel === 'string' ? parsed.channel : null
|
|
34
|
+
const ts = typeof parsed.ts === 'string' ? parsed.ts : null
|
|
35
|
+
if (channel === null || ts === null) return null
|
|
36
|
+
return { channel, ts }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function encodeSlackRemovalRef(target: SlackReactionRemovalTarget): ReactionRef {
|
|
40
|
+
return { adapter: 'slack-bot', value: JSON.stringify({ op: 'remove', ...target }) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function decodeSlackRemovalRef(ref: ReactionRef): SlackReactionRemovalTarget | null {
|
|
44
|
+
if (ref.adapter !== 'slack-bot') return null
|
|
45
|
+
const parsed = parseRecord(ref.value)
|
|
46
|
+
if (parsed === null || parsed.op !== 'remove') return null
|
|
47
|
+
const channel = typeof parsed.channel === 'string' ? parsed.channel : null
|
|
48
|
+
const ts = typeof parsed.ts === 'string' ? parsed.ts : null
|
|
49
|
+
const emoji = typeof parsed.emoji === 'string' ? parsed.emoji : null
|
|
50
|
+
if (channel === null || ts === null || emoji === null) return null
|
|
51
|
+
return { channel, ts, emoji }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Slack accepts any custom-emoji name the workspace has, so unlike GitHub there
|
|
55
|
+
// is no fixed allow-list to validate against up front — an unknown name comes
|
|
56
|
+
// back as `invalid_name` from the API, which we map to `unsupported`. We only
|
|
57
|
+
// strip surrounding colons here; the SDK does the same, but normalizing first
|
|
58
|
+
// keeps the removal ref's stored name canonical.
|
|
59
|
+
function normalizeEmoji(emoji: string): string {
|
|
60
|
+
return emoji.replace(/^:|:$/g, '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createSlackReactionCallback(deps: { client: Pick<SlackBotClient, 'addReaction'> }): ReactionCallback {
|
|
64
|
+
return async (req): Promise<ReactionResult> => {
|
|
65
|
+
if (req.adapter !== 'slack-bot') {
|
|
66
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
67
|
+
}
|
|
68
|
+
const target = decodeSlackReactionRef(req.reactionRef)
|
|
69
|
+
if (target === null) return { ok: false, error: 'unparseable slack reaction ref', code: 'unsupported' }
|
|
70
|
+
const emoji = normalizeEmoji(req.emoji)
|
|
71
|
+
try {
|
|
72
|
+
await deps.client.addReaction(target.channel, target.ts, emoji)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// `already_reacted` is the desired end state, not a failure: a duplicate
|
|
75
|
+
// engage (or a retried tool call) that re-adds the same emoji must read
|
|
76
|
+
// as success so the model/runtime don't surface a spurious error.
|
|
77
|
+
const code = slackErrorCode(err)
|
|
78
|
+
if (code === 'already_reacted') {
|
|
79
|
+
return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, error: withScopeHint(code, describe(err)), code: classifySlackError(code) }
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createSlackRemoveReactionCallback(deps: {
|
|
88
|
+
client: Pick<SlackBotClient, 'removeReaction'>
|
|
89
|
+
}): RemoveReactionCallback {
|
|
90
|
+
return async (req): Promise<ReactionResult> => {
|
|
91
|
+
if (req.adapter !== 'slack-bot') {
|
|
92
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
93
|
+
}
|
|
94
|
+
const target = decodeSlackRemovalRef(req.reactionRef)
|
|
95
|
+
if (target === null) return { ok: false, error: 'unparseable slack reaction removal ref', code: 'unsupported' }
|
|
96
|
+
try {
|
|
97
|
+
await deps.client.removeReaction(target.channel, target.ts, target.emoji)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// `no_reaction` means the reaction is already gone — the desired end
|
|
100
|
+
// state for a removal, so treat it as success (idempotent), mirroring the
|
|
101
|
+
// `already_reacted` handling on the add path.
|
|
102
|
+
const code = slackErrorCode(err)
|
|
103
|
+
if (code === 'no_reaction') return { ok: true }
|
|
104
|
+
return { ok: false, error: describe(err), code: classifySlackError(code) }
|
|
105
|
+
}
|
|
106
|
+
return { ok: true }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// SlackBotError carries the raw Slack API error string on `.code`. We read it
|
|
111
|
+
// structurally (not by instanceof) so a re-thrown or wrapped error still maps
|
|
112
|
+
// correctly, falling back to the message when no code is present.
|
|
113
|
+
function slackErrorCode(err: unknown): string | null {
|
|
114
|
+
if (typeof err === 'object' && err !== null && 'code' in err) {
|
|
115
|
+
const code = (err as { code: unknown }).code
|
|
116
|
+
if (typeof code === 'string') return code
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// `reactions:write` is the scope the bot token needs for both add and remove.
|
|
122
|
+
// On `missing_scope` the bare Slack error is uninformative, so append the
|
|
123
|
+
// concrete operator fix — mirroring GitHub's permission-guidance precedent —
|
|
124
|
+
// since autoReactOnEngage surfaces this to host logs on every engaged inbound
|
|
125
|
+
// until the scope is granted.
|
|
126
|
+
function withScopeHint(code: string | null, error: string): string {
|
|
127
|
+
if (code !== 'missing_scope') return error
|
|
128
|
+
return `${error} (Slack bot token needs the \`reactions:write\` scope; reinstall/reauthorize the app with that scope.)`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function classifySlackError(code: string | null): ReactionErrorCode {
|
|
132
|
+
switch (code) {
|
|
133
|
+
case 'invalid_name':
|
|
134
|
+
case 'no_item_specified':
|
|
135
|
+
return 'unsupported'
|
|
136
|
+
case 'missing_scope':
|
|
137
|
+
case 'not_in_channel':
|
|
138
|
+
case 'is_archived':
|
|
139
|
+
case 'not_authed':
|
|
140
|
+
case 'invalid_auth':
|
|
141
|
+
return 'permission-denied'
|
|
142
|
+
case 'message_not_found':
|
|
143
|
+
case 'channel_not_found':
|
|
144
|
+
return 'not-found'
|
|
145
|
+
case 'ratelimited':
|
|
146
|
+
case 'rate_limited':
|
|
147
|
+
return 'rate-limited'
|
|
148
|
+
default:
|
|
149
|
+
return 'transient'
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseRecord(value: string): Record<string, unknown> | null {
|
|
154
|
+
let parsed: unknown
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(value)
|
|
157
|
+
} catch {
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
161
|
+
? (parsed as Record<string, unknown>)
|
|
162
|
+
: null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function describe(err: unknown): string {
|
|
166
|
+
return err instanceof Error ? err.message : String(err)
|
|
167
|
+
}
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
type SlackInboundMessageEvent,
|
|
44
44
|
} from './slack-bot-classify'
|
|
45
45
|
import { createSlackDedupe } from './slack-bot-dedupe'
|
|
46
|
+
import { createSlackReactionCallback, createSlackRemoveReactionCallback } from './slack-bot-reactions'
|
|
46
47
|
import { enrichSlackReferenceContext } from './slack-bot-reference'
|
|
47
48
|
import {
|
|
48
49
|
buildSlashAckPayload,
|
|
@@ -997,6 +998,9 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
997
998
|
|
|
998
999
|
const fetchAttachmentCallback = createFetchAttachmentCallback({ client, logger })
|
|
999
1000
|
|
|
1001
|
+
const reactionCallback = createSlackReactionCallback({ client })
|
|
1002
|
+
const removeReactionCallback = createSlackRemoveReactionCallback({ client })
|
|
1003
|
+
|
|
1000
1004
|
const dedupe = createSlackDedupe()
|
|
1001
1005
|
|
|
1002
1006
|
const handleSlashCommand = createSlashCommandHandler({
|
|
@@ -1206,6 +1210,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1206
1210
|
})
|
|
1207
1211
|
|
|
1208
1212
|
options.router.registerOutbound('slack-bot', outboundCallback)
|
|
1213
|
+
options.router.registerReaction('slack-bot', reactionCallback)
|
|
1214
|
+
options.router.registerRemoveReaction('slack-bot', removeReactionCallback)
|
|
1209
1215
|
options.router.registerTyping('slack-bot', typingCallback)
|
|
1210
1216
|
options.router.registerChannelNameResolver('slack-bot', channelResolver)
|
|
1211
1217
|
options.router.registerSelfIdentity('slack-bot', selfIdentityResolver)
|
|
@@ -1216,6 +1222,22 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1216
1222
|
try {
|
|
1217
1223
|
await listener.start()
|
|
1218
1224
|
} catch (err) {
|
|
1225
|
+
// Listener failed after registration — roll back every callback so a
|
|
1226
|
+
// failed start leaves no router state behind (stop() returns early on
|
|
1227
|
+
// !started and would otherwise skip cleanup), mirroring the github
|
|
1228
|
+
// adapter's rollback path.
|
|
1229
|
+
options.router.unregisterOutbound('slack-bot', outboundCallback)
|
|
1230
|
+
options.router.unregisterReaction('slack-bot', reactionCallback)
|
|
1231
|
+
options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
|
|
1232
|
+
options.router.unregisterTyping('slack-bot', typingCallback)
|
|
1233
|
+
options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
|
|
1234
|
+
options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
|
|
1235
|
+
options.router.unregisterHistory('slack-bot', historyCallback)
|
|
1236
|
+
options.router.unregisterFetchAttachment('slack-bot', fetchAttachmentCallback)
|
|
1237
|
+
options.router.unregisterMembership('slack-bot', membershipResolver)
|
|
1238
|
+
listener = null
|
|
1239
|
+
botUserId = null
|
|
1240
|
+
teamId = null
|
|
1219
1241
|
started = false
|
|
1220
1242
|
logger.error(`[slack-bot] listener start failed: ${describe(err)}`)
|
|
1221
1243
|
throw err
|
|
@@ -1226,6 +1248,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1226
1248
|
if (!started) return
|
|
1227
1249
|
started = false
|
|
1228
1250
|
options.router.unregisterOutbound('slack-bot', outboundCallback)
|
|
1251
|
+
options.router.unregisterReaction('slack-bot', reactionCallback)
|
|
1252
|
+
options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
|
|
1229
1253
|
options.router.unregisterTyping('slack-bot', typingCallback)
|
|
1230
1254
|
options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
|
|
1231
1255
|
options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
|
package/src/channels/router.ts
CHANGED
|
@@ -2854,7 +2854,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2854
2854
|
return
|
|
2855
2855
|
}
|
|
2856
2856
|
|
|
2857
|
-
const { text:
|
|
2857
|
+
const { text: candidateText, source } = candidate
|
|
2858
|
+
let assistantText = candidateText
|
|
2858
2859
|
|
|
2859
2860
|
if (endsWithNoReplySignal(assistantText)) {
|
|
2860
2861
|
const leakedReasoning = !isNoReplySignal(assistantText)
|
|
@@ -2874,10 +2875,49 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2874
2875
|
return
|
|
2875
2876
|
}
|
|
2876
2877
|
|
|
2877
|
-
|
|
2878
|
-
|
|
2878
|
+
// Plain-text tool-call leak: the model serialized a channel tool call as
|
|
2879
|
+
// ordinary prose instead of producing a real tool call (a Kimi-on-Fireworks
|
|
2880
|
+
// failure mode — see `isLikelyPlainTextChannelToolCall`). We can't post the
|
|
2881
|
+
// raw `channel_reply({...})` serialization to the channel, but for
|
|
2882
|
+
// reply/send the model's *intent* is unambiguous: deliver the `text` arg.
|
|
2883
|
+
// Extract it and recover the actual message. `skip_response` is the
|
|
2884
|
+
// opposite — a genuine decline — so it stays suppressed.
|
|
2885
|
+
const plainTextToolCallKind = getPlainTextChannelToolCallKind(assistantText)
|
|
2886
|
+
if (plainTextToolCallKind === 'skip') {
|
|
2887
|
+
logger.warn(
|
|
2888
|
+
`[channels] ${live.keyId}: suppressed plain_text_channel_skip_response text_len=${assistantText.length}`,
|
|
2889
|
+
)
|
|
2879
2890
|
return
|
|
2880
2891
|
}
|
|
2892
|
+
if (plainTextToolCallKind !== null) {
|
|
2893
|
+
const extracted = extractPlainTextChannelToolCallText(assistantText)
|
|
2894
|
+
// Unextractable (no `text` arg, empty value, or fully-truncated): fall
|
|
2895
|
+
// back to the historical safe behavior — drop it rather than leak plumbing.
|
|
2896
|
+
if (extracted === null) {
|
|
2897
|
+
logger.warn(
|
|
2898
|
+
`[channels] ${live.keyId}: suppressed unextractable_plain_text_channel_tool_call text_len=${assistantText.length}`,
|
|
2899
|
+
)
|
|
2900
|
+
return
|
|
2901
|
+
}
|
|
2902
|
+
// The extracted value is still untrusted model output: if it is itself a
|
|
2903
|
+
// no-reply signal, an empty-response sentinel, or another (nested) leaked
|
|
2904
|
+
// tool call, suppress it through the same guards rather than re-leaking.
|
|
2905
|
+
if (
|
|
2906
|
+
endsWithNoReplySignal(extracted) ||
|
|
2907
|
+
isUpstreamEmptyResponseSentinel(extracted) ||
|
|
2908
|
+
isLikelyKimiChannelToolLeak(extracted) ||
|
|
2909
|
+
isLikelyPlainTextChannelToolCall(extracted)
|
|
2910
|
+
) {
|
|
2911
|
+
logger.warn(
|
|
2912
|
+
`[channels] ${live.keyId}: suppressed plain_text_channel_tool_call (unsafe extracted text) text_len=${extracted.length}`,
|
|
2913
|
+
)
|
|
2914
|
+
return
|
|
2915
|
+
}
|
|
2916
|
+
logger.warn(
|
|
2917
|
+
`[channels] ${live.keyId}: recovered plain_text_channel_tool_call kind=${plainTextToolCallKind} text_len=${extracted.length}`,
|
|
2918
|
+
)
|
|
2919
|
+
assistantText = extracted
|
|
2920
|
+
}
|
|
2881
2921
|
|
|
2882
2922
|
// `source` distinguishes the three recovery shapes for log triage:
|
|
2883
2923
|
// - 'leaf': the assistant message IS the leaf with stopReason 'stop'
|
|
@@ -3233,6 +3273,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3233
3273
|
error?: string
|
|
3234
3274
|
},
|
|
3235
3275
|
): { kind: 'delivered'; keyId: string } => {
|
|
3276
|
+
const adapter = live.keyId.split(':', 1)[0] ?? ''
|
|
3236
3277
|
const text = renderSubagentCompletionReminder({
|
|
3237
3278
|
subagent: args.subagent,
|
|
3238
3279
|
taskId: args.taskId,
|
|
@@ -3240,6 +3281,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3240
3281
|
durationMs: args.durationMs,
|
|
3241
3282
|
...(args.error !== undefined ? { error: args.error } : {}),
|
|
3242
3283
|
channel: true,
|
|
3284
|
+
adapter,
|
|
3243
3285
|
})
|
|
3244
3286
|
live.pendingSystemReminders.push(text)
|
|
3245
3287
|
// The reminder tells the agent to fetch this result now; clear the
|
|
@@ -4231,8 +4273,12 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
|
|
|
4231
4273
|
//
|
|
4232
4274
|
// Structural-only detection (NOT a substring search): the trimmed text must
|
|
4233
4275
|
// *start* with `channel_reply(`, `channel_send(`, or `skip_response(`, and
|
|
4234
|
-
// that opening paren must enclose at least one `"` (the
|
|
4235
|
-
//
|
|
4276
|
+
// that opening paren must enclose at least one quote — `"` or `'` (the
|
|
4277
|
+
// serialized argument). The single-quote arm matters because the extractor
|
|
4278
|
+
// recovers single-quoted values too; if the classifier only matched `"`, a
|
|
4279
|
+
// single-quoted leak like `channel_reply({text: 'hi'})` would bypass the
|
|
4280
|
+
// extractor and post raw plumbing. This deliberately matches the leak shape
|
|
4281
|
+
// while letting prose that merely
|
|
4236
4282
|
// *mentions* a tool name (e.g. "I would normally call channel_reply here
|
|
4237
4283
|
// but...") reach the user — that false-positive class is already locked in by
|
|
4238
4284
|
// the `still recovers prose that mentions channel_reply` test.
|
|
@@ -4240,12 +4286,150 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
|
|
|
4240
4286
|
// The trailing close paren is NOT required: the model sometimes truncates
|
|
4241
4287
|
// mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
|
|
4242
4288
|
// just as user-hostile as the full shape.
|
|
4243
|
-
const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(
|
|
4289
|
+
const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(channel_reply|channel_send|skip_response)\s*\(\s*[^)]*["']/
|
|
4290
|
+
|
|
4291
|
+
export type PlainTextChannelToolCallKind = 'reply' | 'send' | 'skip'
|
|
4292
|
+
|
|
4293
|
+
export function getPlainTextChannelToolCallKind(text: string): PlainTextChannelToolCallKind | null {
|
|
4294
|
+
const match = PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.exec(text.trim())
|
|
4295
|
+
if (match === null) return null
|
|
4296
|
+
switch (match[1]) {
|
|
4297
|
+
case 'channel_reply':
|
|
4298
|
+
return 'reply'
|
|
4299
|
+
case 'channel_send':
|
|
4300
|
+
return 'send'
|
|
4301
|
+
case 'skip_response':
|
|
4302
|
+
return 'skip'
|
|
4303
|
+
default:
|
|
4304
|
+
return null
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4244
4307
|
|
|
4245
4308
|
export function isLikelyPlainTextChannelToolCall(text: string): boolean {
|
|
4246
|
-
return
|
|
4309
|
+
return getPlainTextChannelToolCallKind(text) !== null
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
// Tolerant single-purpose scanner that pulls the `text` argument out of a
|
|
4313
|
+
// plain-text-serialized `channel_reply(...)` / `channel_send(...)` leak. A
|
|
4314
|
+
// single regex covering every shape (double/single/unquoted keys, escaped
|
|
4315
|
+
// quotes, mid-serialization truncation) is fragile, so this walks the string
|
|
4316
|
+
// once and extracts only the first string-valued `text` property. `channel_send`
|
|
4317
|
+
// also carries `adapter`/`chat`/`thread`, which are intentionally ignored —
|
|
4318
|
+
// recovery always routes back through the current channel, never a
|
|
4319
|
+
// model-supplied destination. Returns null when no recoverable, non-empty
|
|
4320
|
+
// `text` value is present so the caller can fall back to suppression.
|
|
4321
|
+
export function extractPlainTextChannelToolCallText(text: string): string | null {
|
|
4322
|
+
const trimmed = text.trim()
|
|
4323
|
+
if (!/^(?:channel_reply|channel_send)\s*\(/.test(trimmed)) return null
|
|
4324
|
+
|
|
4325
|
+
// Walk the serialization once, honoring a `text` key only at the top level of
|
|
4326
|
+
// the argument object (braceDepth 1, outside any array). Two failure classes
|
|
4327
|
+
// motivate the bookkeeping: a `text:` inside an earlier quoted value, e.g.
|
|
4328
|
+
// `channel_send({ reason: "see text: here", text: "real" })`, and a `text:`
|
|
4329
|
+
// inside a *nested* object, e.g. `channel_reply({ meta: { text: "x" }, text:
|
|
4330
|
+
// "real" })`. Skipping string literals defeats the first; tracking
|
|
4331
|
+
// brace/bracket depth and matching keys only at top level defeats the second.
|
|
4332
|
+
// Either way the scanner lands on the real reply instead of leaking the wrong
|
|
4333
|
+
// value or dropping the message.
|
|
4334
|
+
let braceDepth = 0
|
|
4335
|
+
let bracketDepth = 0
|
|
4336
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
4337
|
+
const ch = trimmed[i]!
|
|
4338
|
+
|
|
4339
|
+
if (ch === '"' || ch === "'") {
|
|
4340
|
+
i = skipStringLiteral(trimmed, i, ch)
|
|
4341
|
+
continue
|
|
4342
|
+
}
|
|
4343
|
+
|
|
4344
|
+
if (ch === '{') {
|
|
4345
|
+
braceDepth++
|
|
4346
|
+
if (braceDepth === 1 && bracketDepth === 0) {
|
|
4347
|
+
const value = readTextKeyValueAt(trimmed, i + 1)
|
|
4348
|
+
if (value !== undefined) return value
|
|
4349
|
+
}
|
|
4350
|
+
continue
|
|
4351
|
+
}
|
|
4352
|
+
if (ch === '}') {
|
|
4353
|
+
if (braceDepth > 0) braceDepth--
|
|
4354
|
+
continue
|
|
4355
|
+
}
|
|
4356
|
+
if (ch === '[') {
|
|
4357
|
+
bracketDepth++
|
|
4358
|
+
continue
|
|
4359
|
+
}
|
|
4360
|
+
if (ch === ']') {
|
|
4361
|
+
if (bracketDepth > 0) bracketDepth--
|
|
4362
|
+
continue
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
if (ch === ',' && braceDepth === 1 && bracketDepth === 0) {
|
|
4366
|
+
const value = readTextKeyValueAt(trimmed, i + 1)
|
|
4367
|
+
if (value !== undefined) return value
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
return null
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
// Returns the recovered value (string or null) when a `text` key starts at
|
|
4375
|
+
// `from`, or undefined when no `text` key is present there so the scanner keeps
|
|
4376
|
+
// walking. The null/undefined split lets a malformed `text` value short-circuit
|
|
4377
|
+
// to suppression while a non-`text` delimiter is simply skipped.
|
|
4378
|
+
function readTextKeyValueAt(s: string, from: number): string | null | undefined {
|
|
4379
|
+
const afterKey = matchTextKey(s, from)
|
|
4380
|
+
if (afterKey === null) return undefined
|
|
4381
|
+
|
|
4382
|
+
const quote = s[afterKey]
|
|
4383
|
+
if (quote !== '"' && quote !== "'") return null
|
|
4384
|
+
return readStringValue(s, afterKey + 1, quote)
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
// Returns the closing-quote index, or the last index when the literal is
|
|
4388
|
+
// truncated, so the caller's `i++` resumes past the consumed string.
|
|
4389
|
+
function skipStringLiteral(s: string, openIdx: number, quote: string): number {
|
|
4390
|
+
let escaped = false
|
|
4391
|
+
for (let i = openIdx + 1; i < s.length; i++) {
|
|
4392
|
+
const ch = s[i]!
|
|
4393
|
+
if (escaped) {
|
|
4394
|
+
escaped = false
|
|
4395
|
+
continue
|
|
4396
|
+
}
|
|
4397
|
+
if (ch === '\\') {
|
|
4398
|
+
escaped = true
|
|
4399
|
+
continue
|
|
4400
|
+
}
|
|
4401
|
+
if (ch === quote) return i
|
|
4402
|
+
}
|
|
4403
|
+
return s.length
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
function matchTextKey(s: string, from: number): number | null {
|
|
4407
|
+
const m = /^\s*(?:"text"|'text'|text)\s*:\s*/.exec(s.slice(from))
|
|
4408
|
+
return m === null ? null : from + m[0].length
|
|
4247
4409
|
}
|
|
4248
4410
|
|
|
4411
|
+
function readStringValue(s: string, from: number, quote: string): string | null {
|
|
4412
|
+
let value = ''
|
|
4413
|
+
let escaped = false
|
|
4414
|
+
for (let i = from; i < s.length; i++) {
|
|
4415
|
+
const ch = s[i]!
|
|
4416
|
+
if (escaped) {
|
|
4417
|
+
value += ESCAPE_REPLACEMENTS[ch] ?? ch
|
|
4418
|
+
escaped = false
|
|
4419
|
+
continue
|
|
4420
|
+
}
|
|
4421
|
+
if (ch === '\\') {
|
|
4422
|
+
escaped = true
|
|
4423
|
+
continue
|
|
4424
|
+
}
|
|
4425
|
+
if (ch === quote) break
|
|
4426
|
+
value += ch
|
|
4427
|
+
}
|
|
4428
|
+
return value.trim().length > 0 ? value : null
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
const ESCAPE_REPLACEMENTS: Record<string, string> = { n: '\n', r: '\r', t: '\t' }
|
|
4432
|
+
|
|
4249
4433
|
function describe(err: unknown): string {
|
|
4250
4434
|
return err instanceof Error ? err.message : String(err)
|
|
4251
4435
|
}
|
package/src/channels/schema.ts
CHANGED
|
@@ -125,12 +125,53 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
|
125
125
|
'discussion_comment.created',
|
|
126
126
|
'issues.opened',
|
|
127
127
|
'pull_request.opened',
|
|
128
|
+
'pull_request.ready_for_review',
|
|
128
129
|
'pull_request.review_requested',
|
|
129
130
|
'pull_request.review_request_removed',
|
|
130
131
|
'discussion.created',
|
|
131
132
|
'pull_request_review.submitted',
|
|
132
133
|
] as const
|
|
133
134
|
|
|
135
|
+
// Prior values of DEFAULT_GITHUB_EVENT_ALLOWLIST that shipped in releases and
|
|
136
|
+
// were seeded verbatim into typeclaw.json. Kept as historical record so the
|
|
137
|
+
// migration can recognize and unfreeze configs created by those versions.
|
|
138
|
+
// NEVER edit these in place — they are snapshots of what was on disk.
|
|
139
|
+
// - v1: 7-event default, shipped 0.5.1–0.10.0 (commit fe4f3a8)
|
|
140
|
+
const GITHUB_EVENT_ALLOWLIST_V1 = [
|
|
141
|
+
'issue_comment.created',
|
|
142
|
+
'pull_request_review_comment.created',
|
|
143
|
+
'discussion_comment.created',
|
|
144
|
+
'issues.opened',
|
|
145
|
+
'pull_request.opened',
|
|
146
|
+
'discussion.created',
|
|
147
|
+
'pull_request_review.submitted',
|
|
148
|
+
] as const
|
|
149
|
+
// - v2: added review_requested + review_request_removed, shipped 0.11.0+ (commit 4f365ce)
|
|
150
|
+
const GITHUB_EVENT_ALLOWLIST_V2 = [
|
|
151
|
+
'issue_comment.created',
|
|
152
|
+
'pull_request_review_comment.created',
|
|
153
|
+
'discussion_comment.created',
|
|
154
|
+
'issues.opened',
|
|
155
|
+
'pull_request.opened',
|
|
156
|
+
'pull_request.review_requested',
|
|
157
|
+
'pull_request.review_request_removed',
|
|
158
|
+
'discussion.created',
|
|
159
|
+
'pull_request_review.submitted',
|
|
160
|
+
] as const
|
|
161
|
+
|
|
162
|
+
// Every event-allowlist that `channel add` / `init` has ever seeded verbatim
|
|
163
|
+
// into typeclaw.json, oldest first, current default last. The legacy-shape
|
|
164
|
+
// migration uses this to tell a seeded default (safe to strip so the config
|
|
165
|
+
// re-tracks the shipped default) from a user's deliberate customization (must
|
|
166
|
+
// be preserved). Append the prior array here — never edit in place — whenever
|
|
167
|
+
// DEFAULT_GITHUB_EVENT_ALLOWLIST changes, or configs from the old version stay
|
|
168
|
+
// frozen and the migration starts eating user edits.
|
|
169
|
+
export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
|
|
170
|
+
GITHUB_EVENT_ALLOWLIST_V1,
|
|
171
|
+
GITHUB_EVENT_ALLOWLIST_V2,
|
|
172
|
+
DEFAULT_GITHUB_EVENT_ALLOWLIST,
|
|
173
|
+
]
|
|
174
|
+
|
|
134
175
|
// Which pull_request webhook action triggers an agent code review. The two
|
|
135
176
|
// event values are GitHub's bare PR action names (the `pull_request.` event
|
|
136
177
|
// prefix is implied by this field living under the review config); `off` is the
|