typeclaw 0.27.0 → 0.28.1
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/scripts/generate-schema.ts +4 -6
- package/src/agent/index.ts +26 -4
- package/src/agent/multimodal/look-at.ts +1 -2
- package/src/agent/provider-error.ts +33 -1
- package/src/agent/tools/channel-fetch-attachment.ts +1 -2
- package/src/agent/tools/channel-react.ts +9 -3
- package/src/agent/tools/channel-reply.ts +52 -1
- package/src/agent/tools/channel-send.ts +115 -1
- package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/README.md +3 -21
- package/src/bundled-plugins/memory/index.ts +1 -149
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +103 -0
- package/src/channels/adapters/github/index.ts +10 -0
- package/src/channels/adapters/github/review-state.ts +137 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
- package/src/channels/github-false-receipt.ts +87 -0
- package/src/channels/github-rereview-guard.ts +76 -0
- package/src/channels/github-review-claim.ts +92 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +181 -7
- package/src/channels/schema.ts +20 -5
- package/src/channels/types.ts +31 -0
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- package/src/config/config.ts +19 -288
- package/src/container/start.ts +0 -2
- package/src/cron/index.ts +3 -44
- package/src/cron/schema.ts +2 -96
- package/src/init/gitignore.ts +1 -2
- package/src/inspect/transcript-view.ts +10 -0
- package/src/secrets/defaults.ts +1 -18
- package/src/secrets/index.ts +0 -2
- package/src/secrets/schema.ts +4 -90
- package/src/secrets/storage.ts +0 -2
- package/src/server/index.ts +11 -5
- package/src/shared/protocol.ts +18 -6
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/src/tui/format.ts +13 -0
- package/src/tui/index.ts +21 -7
- package/typeclaw.schema.json +1 -0
- package/src/agent/tools/normalize-ref.ts +0 -11
- package/src/bundled-plugins/memory/migration.ts +0 -633
- package/src/secrets/migrate-kakaotalk.ts +0 -82
- package/src/secrets/migrate.ts +0 -96
package/src/channels/router.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
StickyLedger,
|
|
33
33
|
type EngagementDecision,
|
|
34
34
|
} from './engagement'
|
|
35
|
+
import { resetReviewTurn } from './github-review-turn-ledger'
|
|
35
36
|
import {
|
|
36
37
|
MEMBERSHIP_COLD_FETCH_TIMEOUT_MS,
|
|
37
38
|
type MembershipCount,
|
|
@@ -74,6 +75,9 @@ import type {
|
|
|
74
75
|
ReactionRequest,
|
|
75
76
|
ReactionResult,
|
|
76
77
|
ResolvedChannelNames,
|
|
78
|
+
ReviewStateRequest,
|
|
79
|
+
ReviewStateResolver,
|
|
80
|
+
ReviewStateResult,
|
|
77
81
|
ReviewThreadResolveRequest,
|
|
78
82
|
ReviewThreadResolveResult,
|
|
79
83
|
ReviewThreadResolver,
|
|
@@ -177,6 +181,44 @@ export const MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN = 3
|
|
|
177
181
|
// including reasoning). Deliberately NOT lowered in `providers.ts`, where
|
|
178
182
|
// `maxTokens` is the model's true capability that compaction math reads.
|
|
179
183
|
export const CHANNEL_MAX_OUTPUT_TOKENS = 4096
|
|
184
|
+
// Ceiling on automatic re-prompts for a turn that ended with NO user-facing
|
|
185
|
+
// reply AND no attempted send — the pure "the model burned its budget thinking
|
|
186
|
+
// and produced nothing" failure. The canonical trigger is Fireworks'
|
|
187
|
+
// kimi-k2p6-turbo spiraling into a long reasoning loop on an ambiguous request
|
|
188
|
+
// until it hits CHANNEL_MAX_OUTPUT_TOKENS (`stopReason: 'length'`); the same
|
|
189
|
+
// path also catches a provider/router `aborted` leaf that left no recoverable
|
|
190
|
+
// prose. Each retry injects EMPTY_TURN_RETRY_NUDGE as a reminder-only turn (no
|
|
191
|
+
// new inbound) so `drain()` re-runs `session.prompt()` against the same branch.
|
|
192
|
+
// Bounded because a genuinely stuck model would otherwise re-loop forever; on
|
|
193
|
+
// exhaustion the user gets EMPTY_TURN_FALLBACK_TEXT instead of dead air. Reset
|
|
194
|
+
// at turn start alongside `turnSeq`. Deliberately NOT applied to turns that
|
|
195
|
+
// ATTEMPTED a send this turn (skip-locked or policy-denied) — those already
|
|
196
|
+
// thrashed the send path, so a re-prompt would just re-thrash; they skip
|
|
197
|
+
// straight to the fallback. See validateChannelTurn's candidate===null branch.
|
|
198
|
+
export const MAX_EMPTY_TURN_RETRIES = 2
|
|
199
|
+
// Reminder-only nudge injected before an empty-turn retry. Uses the repo's
|
|
200
|
+
// SYSTEM MESSAGE framing (see composeTurnPrompt) so persona-rich models do not
|
|
201
|
+
// reply to the notice itself. Neutral by design: it asks for a direct reply
|
|
202
|
+
// without prescribing length or tone, matching the chosen "just retry" posture.
|
|
203
|
+
export const EMPTY_TURN_RETRY_NUDGE = [
|
|
204
|
+
'---',
|
|
205
|
+
'**[SYSTEM MESSAGE — not from a human]**',
|
|
206
|
+
'',
|
|
207
|
+
'Your previous turn ended without sending any reply to the channel. This is',
|
|
208
|
+
'an automated signal from the channel router, not a message from anyone in',
|
|
209
|
+
'the chat. **Do not acknowledge or reply to this notice itself.**',
|
|
210
|
+
'',
|
|
211
|
+
'Respond to the last user message now with a direct answer via your channel',
|
|
212
|
+
'reply tool. If you genuinely have nothing to say, reply with `NO_REPLY`.',
|
|
213
|
+
'',
|
|
214
|
+
'---',
|
|
215
|
+
].join('\n')
|
|
216
|
+
// Posted to the channel (via the `source:'system'` one-shot bypass) when an
|
|
217
|
+
// empty turn cannot be recovered AND retries are exhausted (or are skipped
|
|
218
|
+
// because the turn thrashed the send path). Replaces the historical silent
|
|
219
|
+
// drop so the human is never left staring at dead air after a degenerate turn.
|
|
220
|
+
export const EMPTY_TURN_FALLBACK_TEXT =
|
|
221
|
+
"⚠️ I got stuck putting together a reply and couldn't finish. Could you rephrase or try again?"
|
|
180
222
|
// Rolling window for outbound send-rate telemetry. 5s matches Discord's
|
|
181
223
|
// rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
|
|
182
224
|
// 1 msg/s sustained. The window is observational; exceeding the burst
|
|
@@ -480,6 +522,14 @@ type LiveSession = {
|
|
|
480
522
|
// livelock the byte-identical loop-guard misses. Reset at turn start and
|
|
481
523
|
// cleared per-target on a successful delivery to that target.
|
|
482
524
|
policyDeniedToolSendsThisTurn: Map<string, number>
|
|
525
|
+
// Count of automatic empty-turn re-prompts already spent on the CURRENT
|
|
526
|
+
// logical turn, bounded by `MAX_EMPTY_TURN_RETRIES`. A "logical turn" spans
|
|
527
|
+
// the original user batch plus any router-injected retry nudges, so this is
|
|
528
|
+
// reset only when a real user/reminder batch starts a fresh turn — NOT on the
|
|
529
|
+
// reminder-only iterations the retry itself queues. `validateChannelTurn`
|
|
530
|
+
// increments it before injecting EMPTY_TURN_RETRY_NUDGE and reads it to decide
|
|
531
|
+
// retry-vs-fallback. See the candidate===null branch.
|
|
532
|
+
emptyTurnRetries: number
|
|
483
533
|
// Stamped by `markTurnSkipped` (called from the `skip_response` tool)
|
|
484
534
|
// with the current `turnSeq`. Read at the top of `validateChannelTurn`:
|
|
485
535
|
// if it matches the just-completed turn, recovery is skipped entirely
|
|
@@ -634,6 +684,12 @@ export type ChannelRouter = {
|
|
|
634
684
|
registerReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
|
|
635
685
|
unregisterReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
|
|
636
686
|
resolveReviewThread: (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
|
|
687
|
+
// Re-review stranding guard support: answers whether the bot still holds a
|
|
688
|
+
// blocking CHANGES_REQUESTED on a PR. Opt-in per adapter like the thread
|
|
689
|
+
// resolver; `getReviewState` answers `unsupported` when none is registered.
|
|
690
|
+
registerReviewStateResolver: (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver) => void
|
|
691
|
+
unregisterReviewStateResolver: (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver) => void
|
|
692
|
+
getReviewState: (req: ReviewStateRequest) => Promise<ReviewStateResult>
|
|
637
693
|
lookupInboundAttachment: (args: ChannelKey & { id: number }) => InboundAttachment | null
|
|
638
694
|
listInboundAttachmentIds: (args: ChannelKey) => readonly number[]
|
|
639
695
|
// Execute a command by name against an existing live session, bypassing
|
|
@@ -904,6 +960,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
904
960
|
const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
|
|
905
961
|
const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
|
|
906
962
|
const reviewThreadResolvers = new Map<ChannelKey['adapter'], ReviewThreadResolver>()
|
|
963
|
+
const reviewStateResolvers = new Map<ChannelKey['adapter'], ReviewStateResolver>()
|
|
907
964
|
const stickyLedger = new StickyLedger()
|
|
908
965
|
// The /help handler reads the live registry to enumerate commands, so it
|
|
909
966
|
// forward-references `commands`. Safe at runtime — the handler only runs on
|
|
@@ -1351,6 +1408,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1351
1408
|
successfulSendsAtTurnStart: 0,
|
|
1352
1409
|
inFlightToolSends: new Map(),
|
|
1353
1410
|
policyDeniedToolSendsThisTurn: new Map(),
|
|
1411
|
+
emptyTurnRetries: 0,
|
|
1354
1412
|
skippedTurn: null,
|
|
1355
1413
|
skipLockedSendTurn: null,
|
|
1356
1414
|
pendingQuoteCandidate: null,
|
|
@@ -1366,6 +1424,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1366
1424
|
}
|
|
1367
1425
|
live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
|
|
1368
1426
|
logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
|
|
1427
|
+
// A provider soft-error (rate/usage limit, billing, malformed response)
|
|
1428
|
+
// ends the turn with no assistant text, so the human otherwise sees
|
|
1429
|
+
// silence. Surface the REDACTED `safeMessage` (never the raw provider
|
|
1430
|
+
// text, which can carry response bodies / URLs / tokens) via a 'system'
|
|
1431
|
+
// send — the same one-shot bypass path validateChannelTurn uses, so it
|
|
1432
|
+
// lands regardless of per-turn send caps and skips the duplicate guard.
|
|
1433
|
+
void send(
|
|
1434
|
+
{
|
|
1435
|
+
adapter: live.key.adapter,
|
|
1436
|
+
workspace: live.key.workspace,
|
|
1437
|
+
chat: live.key.chat,
|
|
1438
|
+
thread: live.key.thread,
|
|
1439
|
+
text: `⚠️ ${err.safeMessage}`,
|
|
1440
|
+
},
|
|
1441
|
+
{ source: 'system' },
|
|
1442
|
+
)
|
|
1443
|
+
.then((result) => {
|
|
1444
|
+
if (!result.ok) {
|
|
1445
|
+
logger.warn(`[channels] ${live.keyId}: provider-error notice send failed: ${result.error}`)
|
|
1446
|
+
}
|
|
1447
|
+
})
|
|
1448
|
+
.catch((sendErr) => {
|
|
1449
|
+
logger.warn(`[channels] ${live.keyId}: provider-error notice send threw: ${describe(sendErr)}`)
|
|
1450
|
+
})
|
|
1369
1451
|
})
|
|
1370
1452
|
live.unsubTodoOutcome = created.session.subscribe((event: unknown) => {
|
|
1371
1453
|
const usage = extractTurnUsage(event)
|
|
@@ -1795,6 +1877,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1795
1877
|
live.consecutiveSends.clear()
|
|
1796
1878
|
live.lastSentText.clear()
|
|
1797
1879
|
live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
|
|
1880
|
+
// A real user batch starts a fresh logical turn → restore the full
|
|
1881
|
+
// empty-turn retry budget. Reset here (batch.length > 0) and NOT in
|
|
1882
|
+
// the per-prompt block below, so the reminder-only iterations the
|
|
1883
|
+
// retry itself queues do not refill the budget and loop forever.
|
|
1884
|
+
live.emptyTurnRetries = 0
|
|
1798
1885
|
} else if (live.lastTurnAuthorId !== null) {
|
|
1799
1886
|
live.currentTurnEngageReactions = []
|
|
1800
1887
|
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
@@ -1844,6 +1931,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1844
1931
|
live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
|
|
1845
1932
|
live.skipLockedSendTurn = null
|
|
1846
1933
|
live.policyDeniedToolSendsThisTurn.clear()
|
|
1934
|
+
resetReviewTurn(live.sessionId)
|
|
1847
1935
|
const isRealUserTurn = batch.length > 0
|
|
1848
1936
|
await fireSessionTurnStart(live, text)
|
|
1849
1937
|
try {
|
|
@@ -2572,6 +2660,26 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2572
2660
|
)
|
|
2573
2661
|
}
|
|
2574
2662
|
|
|
2663
|
+
const registerReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
|
|
2664
|
+
reviewStateResolvers.set(adapter, resolver)
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
const unregisterReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
|
|
2668
|
+
if (reviewStateResolvers.get(adapter) === resolver) {
|
|
2669
|
+
reviewStateResolvers.delete(adapter)
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
const getReviewState = async (req: ReviewStateRequest): Promise<ReviewStateResult> => {
|
|
2674
|
+
const resolver = reviewStateResolvers.get(req.adapter)
|
|
2675
|
+
if (resolver === undefined) {
|
|
2676
|
+
return { ok: false, error: `adapter "${req.adapter}" does not support review-state lookup`, code: 'unsupported' }
|
|
2677
|
+
}
|
|
2678
|
+
return await resolver(req).catch(
|
|
2679
|
+
(err): ReviewStateResult => ({ ok: false, error: describe(err), code: 'transient' }),
|
|
2680
|
+
)
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2575
2683
|
const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
|
|
2576
2684
|
const live = liveSessions.get(channelKeyId(args))
|
|
2577
2685
|
if (live === undefined) return null
|
|
@@ -2842,15 +2950,64 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2842
2950
|
}
|
|
2843
2951
|
if (live.successfulChannelSends > successfulSendsBeforePrompt) return
|
|
2844
2952
|
|
|
2953
|
+
const postEmptyTurnFallback = async (cause: string): Promise<void> => {
|
|
2954
|
+
logger.warn(`[channels] ${live.keyId} empty_turn_fallback cause=${cause}`)
|
|
2955
|
+
const result = await send(
|
|
2956
|
+
{
|
|
2957
|
+
adapter: live.key.adapter,
|
|
2958
|
+
workspace: live.key.workspace,
|
|
2959
|
+
chat: live.key.chat,
|
|
2960
|
+
thread: live.key.thread,
|
|
2961
|
+
text: EMPTY_TURN_FALLBACK_TEXT,
|
|
2962
|
+
},
|
|
2963
|
+
{ source: 'system' },
|
|
2964
|
+
)
|
|
2965
|
+
if (!result.ok) {
|
|
2966
|
+
logger.warn(`[channels] ${live.keyId}: empty-turn fallback send failed: ${result.error}`)
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2845
2970
|
const candidate = recoverableAssistantText(live.session)
|
|
2846
2971
|
if (candidate === null) {
|
|
2847
|
-
//
|
|
2848
|
-
//
|
|
2849
|
-
//
|
|
2850
|
-
//
|
|
2851
|
-
//
|
|
2852
|
-
//
|
|
2853
|
-
|
|
2972
|
+
// No recoverable assistant prose: the turn ended with no usable reply.
|
|
2973
|
+
// Two distinct shapes, handled differently (Option B):
|
|
2974
|
+
//
|
|
2975
|
+
// 1. The model THRASHED the send path this turn — it tried to send but
|
|
2976
|
+
// every attempt was denied (skip-locked, or policy-denied/duplicate/
|
|
2977
|
+
// cap, tracked on skipLockedSendTurn / policyDeniedToolSendsThisTurn).
|
|
2978
|
+
// Re-prompting would just re-thrash, so skip retry and post the
|
|
2979
|
+
// user-facing fallback once.
|
|
2980
|
+
//
|
|
2981
|
+
// 2. The PURE reasoning-loop — no send was ever attempted; the model
|
|
2982
|
+
// burned its budget thinking and produced nothing (the canonical
|
|
2983
|
+
// kimi `stopReason: 'length'` / `aborted` degeneration). Re-prompt up
|
|
2984
|
+
// to MAX_EMPTY_TURN_RETRIES with a neutral nudge; on exhaustion, fall
|
|
2985
|
+
// back. The nudge is injected as a reminder-only turn so drain()'s
|
|
2986
|
+
// while-loop re-runs session.prompt() against the same branch.
|
|
2987
|
+
//
|
|
2988
|
+
// The legitimate empty-state case (a TUI-only check before any user
|
|
2989
|
+
// prompt, no inbound this turn) is excluded: no batch means no real turn
|
|
2990
|
+
// to retry or apologize for — keep the historical silent bail there.
|
|
2991
|
+
const attemptedSendThisTurn =
|
|
2992
|
+
live.skipLockedSendTurn === live.turnSeq || live.policyDeniedToolSendsThisTurn.size > 0
|
|
2993
|
+
|
|
2994
|
+
// Only a TRUNCATED assistant leaf (length/error/aborted) from a real
|
|
2995
|
+
// conversational turn is a degeneration worth retrying. A cold/empty turn
|
|
2996
|
+
// (no inbound author, or no assistant message at all) keeps the historical
|
|
2997
|
+
// silent bail — re-prompting it would manufacture replies to nothing.
|
|
2998
|
+
if (live.currentTurnAuthorId === null || !assistantLeafTruncated(live.session)) {
|
|
2999
|
+
logger.info(`[channels] ${live.keyId}: no recoverable assistant text in branch`)
|
|
3000
|
+
return
|
|
3001
|
+
}
|
|
3002
|
+
if (!attemptedSendThisTurn && live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
|
|
3003
|
+
live.emptyTurnRetries++
|
|
3004
|
+
logger.warn(
|
|
3005
|
+
`[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES}`,
|
|
3006
|
+
)
|
|
3007
|
+
live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
|
|
3008
|
+
return
|
|
3009
|
+
}
|
|
3010
|
+
await postEmptyTurnFallback(attemptedSendThisTurn ? 'send_thrash' : 'retries_exhausted')
|
|
2854
3011
|
return
|
|
2855
3012
|
}
|
|
2856
3013
|
|
|
@@ -3396,6 +3553,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3396
3553
|
registerReviewThreadResolver,
|
|
3397
3554
|
unregisterReviewThreadResolver,
|
|
3398
3555
|
resolveReviewThread,
|
|
3556
|
+
registerReviewStateResolver,
|
|
3557
|
+
unregisterReviewStateResolver,
|
|
3558
|
+
getReviewState,
|
|
3399
3559
|
lookupInboundAttachment,
|
|
3400
3560
|
listInboundAttachmentIds,
|
|
3401
3561
|
executeCommand,
|
|
@@ -4107,6 +4267,20 @@ function recoverableAssistantText(
|
|
|
4107
4267
|
return null
|
|
4108
4268
|
}
|
|
4109
4269
|
|
|
4270
|
+
// True only when the leaf is an assistant message that was CUT OFF mid-output:
|
|
4271
|
+
// `length` (hit the token cap — the canonical kimi reasoning-loop), `error`, or
|
|
4272
|
+
// `aborted`. This is the precise signature of "the model was producing but got
|
|
4273
|
+
// truncated", as distinct from a turn that produced no assistant message at all
|
|
4274
|
+
// (leaf undefined / a non-assistant entry), which is a benign empty/cold turn —
|
|
4275
|
+
// NOT something to re-prompt. The empty-turn retry guard keys off this so it
|
|
4276
|
+
// fires for real degenerations and stays silent for cold sessions.
|
|
4277
|
+
function assistantLeafTruncated(session: AgentSession): boolean {
|
|
4278
|
+
const leaf = session.sessionManager.getLeafEntry()
|
|
4279
|
+
if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return false
|
|
4280
|
+
const stop = leaf.message.stopReason
|
|
4281
|
+
return stop === 'length' || stop === 'error' || stop === 'aborted'
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4110
4284
|
function visibleAssistantText(message: AssistantMessage): string {
|
|
4111
4285
|
return message.content
|
|
4112
4286
|
.filter((block) => block.type === 'text')
|
package/src/channels/schema.ts
CHANGED
|
@@ -107,11 +107,9 @@ const quotedReplySchema = z
|
|
|
107
107
|
.default({ enabled: true, queueDelayMs: DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS })
|
|
108
108
|
|
|
109
109
|
// Deliberately non-strict: a stale on-disk file may still carry the
|
|
110
|
-
// legacy `allow` field
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// exactly what we want — a hard `.strict()` reject would brick recovery
|
|
114
|
-
// for any user mid-migration.
|
|
110
|
+
// legacy `allow` field. Zod silently drops unknown keys here, which is
|
|
111
|
+
// exactly what we want — the field is ignored, not translated, and a hard
|
|
112
|
+
// `.strict()` reject would brick recovery for any user with an old config.
|
|
115
113
|
const adapterSchema = z.object({
|
|
116
114
|
engagement: engagementSchema,
|
|
117
115
|
history: historySchema,
|
|
@@ -128,6 +126,7 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
|
128
126
|
'pull_request.ready_for_review',
|
|
129
127
|
'pull_request.review_requested',
|
|
130
128
|
'pull_request.review_request_removed',
|
|
129
|
+
'pull_request.synchronize',
|
|
131
130
|
'discussion.created',
|
|
132
131
|
'pull_request_review.submitted',
|
|
133
132
|
] as const
|
|
@@ -158,6 +157,21 @@ const GITHUB_EVENT_ALLOWLIST_V2 = [
|
|
|
158
157
|
'discussion.created',
|
|
159
158
|
'pull_request_review.submitted',
|
|
160
159
|
] as const
|
|
160
|
+
// - v3: added ready_for_review, shipped 0.12.0+ (the default just before
|
|
161
|
+
// synchronize was added). Snapshotted here so configs seeded with the
|
|
162
|
+
// pre-synchronize default unfreeze and re-track the new default.
|
|
163
|
+
const GITHUB_EVENT_ALLOWLIST_V3 = [
|
|
164
|
+
'issue_comment.created',
|
|
165
|
+
'pull_request_review_comment.created',
|
|
166
|
+
'discussion_comment.created',
|
|
167
|
+
'issues.opened',
|
|
168
|
+
'pull_request.opened',
|
|
169
|
+
'pull_request.ready_for_review',
|
|
170
|
+
'pull_request.review_requested',
|
|
171
|
+
'pull_request.review_request_removed',
|
|
172
|
+
'discussion.created',
|
|
173
|
+
'pull_request_review.submitted',
|
|
174
|
+
] as const
|
|
161
175
|
|
|
162
176
|
// Every event-allowlist that `channel add` / `init` has ever seeded verbatim
|
|
163
177
|
// into typeclaw.json, oldest first, current default last. The legacy-shape
|
|
@@ -169,6 +183,7 @@ const GITHUB_EVENT_ALLOWLIST_V2 = [
|
|
|
169
183
|
export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
|
|
170
184
|
GITHUB_EVENT_ALLOWLIST_V1,
|
|
171
185
|
GITHUB_EVENT_ALLOWLIST_V2,
|
|
186
|
+
GITHUB_EVENT_ALLOWLIST_V3,
|
|
172
187
|
DEFAULT_GITHUB_EVENT_ALLOWLIST,
|
|
173
188
|
]
|
|
174
189
|
|
package/src/channels/types.ts
CHANGED
|
@@ -382,6 +382,37 @@ export type ReviewThreadResolveResult =
|
|
|
382
382
|
// support review threads never register one; the router answers `unsupported`.
|
|
383
383
|
export type ReviewThreadResolver = (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
|
|
384
384
|
|
|
385
|
+
// A query for "does the bot still owe this PR a verdict?" — i.e. is the bot's
|
|
386
|
+
// latest formal review on the PR a sticky CHANGES_REQUESTED that no later
|
|
387
|
+
// APPROVE/dismissal has cleared. Used by the re-review stranding guard to stop
|
|
388
|
+
// the bot from resolving a thread / posting a close-out ack while it still
|
|
389
|
+
// holds a blocking review (the PR #644 failure: thread resolved + chat ack, but
|
|
390
|
+
// reviewDecision stuck at CHANGES_REQUESTED because neither carries review
|
|
391
|
+
// state). `workspace` is the repo slug `owner/name`; `chat` is `pr:<N>`.
|
|
392
|
+
export type ReviewStateRequest = {
|
|
393
|
+
adapter: AdapterId
|
|
394
|
+
workspace: string
|
|
395
|
+
chat: string
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// `selfBlocking` is the answer the guard acts on: true means the bot's latest
|
|
399
|
+
// effective formal review is its own CHANGES_REQUESTED (COMMENTED reviews are
|
|
400
|
+
// ignored — they never clear the sticky block, GitHub's own rule). `approve`
|
|
401
|
+
// mirrors `channels.github.review.approve` so the guard's denial text can tell
|
|
402
|
+
// the model whether to land a fresh APPROVE or to DISMISS its prior review.
|
|
403
|
+
//
|
|
404
|
+
// On `ok: false` the caller MUST fail closed: an unverifiable review state is
|
|
405
|
+
// treated like a live block, so the bot never claims close-out when the runtime
|
|
406
|
+
// could not confirm the platform-side verdict.
|
|
407
|
+
export type ReviewStateResult =
|
|
408
|
+
| { ok: true; selfBlocking: boolean; approve: boolean }
|
|
409
|
+
| { ok: false; error: string; code?: 'unsupported' | 'not-found' | 'permission-denied' | 'transient' }
|
|
410
|
+
|
|
411
|
+
// Registered per-adapter on the ChannelRouter, last-write-wins like the
|
|
412
|
+
// review-thread resolver. Adapters that never register one make `getReviewState`
|
|
413
|
+
// answer `unsupported`.
|
|
414
|
+
export type ReviewStateResolver = (req: ReviewStateRequest) => Promise<ReviewStateResult>
|
|
415
|
+
|
|
385
416
|
export function channelKeyId(key: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
|
|
386
417
|
return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
|
|
387
418
|
}
|
package/src/cli/channel.ts
CHANGED
|
@@ -629,8 +629,9 @@ async function promptGithubCredentials(cwd: string): Promise<{
|
|
|
629
629
|
message: 'GitHub authentication type',
|
|
630
630
|
options: [
|
|
631
631
|
{ value: 'pat', label: 'Fine-grained personal access token' },
|
|
632
|
-
{ value: 'app', label: 'GitHub App installation token' },
|
|
632
|
+
{ value: 'app', label: 'GitHub App installation token (recommended)' },
|
|
633
633
|
],
|
|
634
|
+
initialValue: 'app',
|
|
634
635
|
})
|
|
635
636
|
if (isCancel(authType)) {
|
|
636
637
|
cancel('Aborted.')
|
package/src/cli/init.ts
CHANGED
|
@@ -1182,8 +1182,9 @@ async function runGithubFlow(cwd: string): Promise<StepResult<CollectedInputs['c
|
|
|
1182
1182
|
message: 'GitHub authentication type',
|
|
1183
1183
|
options: [
|
|
1184
1184
|
{ value: 'pat', label: 'Fine-grained personal access token' },
|
|
1185
|
-
{ value: 'app', label: 'GitHub App installation token' },
|
|
1185
|
+
{ value: 'app', label: 'GitHub App installation token (recommended)' },
|
|
1186
1186
|
],
|
|
1187
|
+
initialValue: 'app',
|
|
1187
1188
|
})
|
|
1188
1189
|
if (isCancel(authType)) return back()
|
|
1189
1190
|
const auth = authType === 'pat' ? await promptGithubPatAuth() : await promptGithubAppAuth()
|