typeclaw 0.10.0 → 0.11.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/README.md +5 -1
- package/package.json +1 -1
- package/src/agent/index.ts +37 -4
- package/src/agent/multimodal/look-at.ts +8 -0
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +3 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/channels/adapters/discord-bot-invite.ts +89 -0
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/adapters/kakaotalk-classify.ts +13 -1
- package/src/channels/adapters/kakaotalk.ts +2 -0
- package/src/channels/router.ts +269 -34
- package/src/channels/schema.ts +8 -7
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +138 -52
- package/src/cli/init.ts +139 -100
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +24 -32
- package/src/cli/prompt-pem.ts +113 -0
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/cli/ui.ts +22 -0
- package/src/compose/discover.ts +5 -0
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +64 -56
- package/src/init/env-file.ts +66 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +5 -1
- package/src/inspect/loop.ts +12 -1
- package/src/inspect/replay.ts +15 -1
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +14 -2
- package/src/server/command-runner.ts +31 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- package/typeclaw.schema.json +25 -7
package/src/channels/router.ts
CHANGED
|
@@ -29,11 +29,7 @@ import {
|
|
|
29
29
|
saveChannelSessions,
|
|
30
30
|
type ChannelSessionRecord,
|
|
31
31
|
} from './persistence'
|
|
32
|
-
import {
|
|
33
|
-
DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS,
|
|
34
|
-
QUOTED_REPLY_EXCERPT_MAX_CHARS,
|
|
35
|
-
type ChannelAdapterConfig,
|
|
36
|
-
} from './schema'
|
|
32
|
+
import { QUOTED_REPLY_EXCERPT_MAX_CHARS, type AdapterId, type ChannelAdapterConfig } from './schema'
|
|
37
33
|
import type {
|
|
38
34
|
ChannelHistoryMessage,
|
|
39
35
|
ChannelKey,
|
|
@@ -66,6 +62,15 @@ export const TYPING_HEARTBEAT_MS = 8000
|
|
|
66
62
|
// platform-side typing forever. Slack Assistant status in particular has a
|
|
67
63
|
// documented 2-minute timeout, so repeatedly refreshing it after that point
|
|
68
64
|
// turns a temporary status into a permanent-looking artifact.
|
|
65
|
+
//
|
|
66
|
+
// The cap is measured from `live.typingStartedAt`, which is refreshed by
|
|
67
|
+
// two signals of life (see `bumpTypingActivity`):
|
|
68
|
+
// 1. Each new `drain()` iteration (a new turn is starting).
|
|
69
|
+
// 2. Each `tool_execution_end` from the agent session (a tool just
|
|
70
|
+
// completed — the prompt is progressing, not stuck).
|
|
71
|
+
// A 2-minute bash command that emits no intermediate events still trips
|
|
72
|
+
// the cap, but a chatty agent running long tools stays under it
|
|
73
|
+
// indefinitely. The cap exists to catch *silence*, not duration.
|
|
69
74
|
export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
|
|
70
75
|
|
|
71
76
|
// Idle GC: a LiveSession whose `lastInboundAt` is older than
|
|
@@ -234,6 +239,19 @@ type ObservedInbound = {
|
|
|
234
239
|
authorIsBot: boolean
|
|
235
240
|
receivedAt: number
|
|
236
241
|
ts: number
|
|
242
|
+
// Distinguishes scrollback that was bulk-loaded at session cold-start
|
|
243
|
+
// (`prefetch`) from messages that actually arrived in the channel after
|
|
244
|
+
// the session went live (`observed`). Both share the same in-memory
|
|
245
|
+
// shape because the model sees them identically in the prompt's
|
|
246
|
+
// "Recent context" block, but the quote-anchor decision must treat them
|
|
247
|
+
// differently: prefetched scrollback is HISTORICAL context, not new
|
|
248
|
+
// chatter that happened between the primary inbound and the agent's
|
|
249
|
+
// reply. Counting prefetch entries as "intervening" would fire the
|
|
250
|
+
// anchor on every fresh-thread first turn (the prefetch stamps
|
|
251
|
+
// `receivedAt = now()` AFTER the inbound was received during ensureLive,
|
|
252
|
+
// so by primary-vs-observed timestamp comparison they always look
|
|
253
|
+
// "later"). See captureQuoteCandidate.
|
|
254
|
+
source: 'prefetch' | 'observed'
|
|
237
255
|
}
|
|
238
256
|
|
|
239
257
|
type LiveSession = {
|
|
@@ -306,6 +324,31 @@ type LiveSession = {
|
|
|
306
324
|
// future hard cap without picking a threshold out of thin air.
|
|
307
325
|
sendTimestamps: Map<string, number[]>
|
|
308
326
|
successfulChannelSends: number
|
|
327
|
+
// Monotonic per-LiveSession turn counter incremented just before each
|
|
328
|
+
// `live.session.prompt(...)` call in `drain()`. Used as a turn identity
|
|
329
|
+
// so `skip_response` can record "I skipped turn N" without leaking
|
|
330
|
+
// across turns. `validateChannelTurn` only honors `skippedTurn` when it
|
|
331
|
+
// equals `turnSeq`; a stale value from a crashed/aborted prior turn is
|
|
332
|
+
// ignored (defensive: an unmatched skippedTurn would otherwise silently
|
|
333
|
+
// drop the next user-facing reply). NOT cleared on drain finally — the
|
|
334
|
+
// counter is purely monotonic; the matching comparison is what protects
|
|
335
|
+
// against stale state.
|
|
336
|
+
turnSeq: number
|
|
337
|
+
// Snapshot of `successfulChannelSends` taken at turn start (same
|
|
338
|
+
// moment `turnSeq` increments). Lets `markTurnSkipped` detect "a
|
|
339
|
+
// channel send already landed in this turn" and reject the skip,
|
|
340
|
+
// making the rejection symmetric with the send-after-skip lock in
|
|
341
|
+
// `send()`: commit to silence or commit to replying, not both,
|
|
342
|
+
// regardless of which order the model tried them in. Updated only at
|
|
343
|
+
// turn start; reads against the live counter elsewhere are intentional.
|
|
344
|
+
successfulSendsAtTurnStart: number
|
|
345
|
+
// Stamped by `markTurnSkipped` (called from the `skip_response` tool)
|
|
346
|
+
// with the current `turnSeq`. Read at the top of `validateChannelTurn`:
|
|
347
|
+
// if it matches the just-completed turn, recovery is skipped entirely
|
|
348
|
+
// (no NO_REPLY check, no Kimi leak check, no assistant-text recovery).
|
|
349
|
+
// The model has explicitly opted out of this turn and we honor that
|
|
350
|
+
// unconditionally. `null` when no skip has been recorded.
|
|
351
|
+
skippedTurn: { turnSeq: number; reason: string } | null
|
|
309
352
|
// Captured by drain() at batch dequeue; read+cleared by send() on the
|
|
310
353
|
// first tool-source send of the turn. The anchor decision (delay
|
|
311
354
|
// threshold + intervening-observed check) is evaluated at SEND time
|
|
@@ -327,6 +370,7 @@ type LiveSession = {
|
|
|
327
370
|
membershipFetch: Promise<MembershipCount | null> | null
|
|
328
371
|
destroyed: boolean
|
|
329
372
|
unsubProviderErrors: (() => void) | null
|
|
373
|
+
unsubTypingActivity: (() => void) | null
|
|
330
374
|
}
|
|
331
375
|
|
|
332
376
|
// `event` is null for command invocations that originated outside the inbound
|
|
@@ -369,6 +413,11 @@ export const TURN_CAP_ERROR =
|
|
|
369
413
|
`Send-cap reached for this turn (${MAX_CHANNEL_SENDS_PER_TURN} messages already sent to this conversation). ` +
|
|
370
414
|
'End your turn now. The user can prompt you again for more output.'
|
|
371
415
|
|
|
416
|
+
export const SKIP_RESPONSE_LOCK_ERROR =
|
|
417
|
+
'You called `skip_response` earlier in this turn, which committed to staying silent. ' +
|
|
418
|
+
'Channel sends are blocked for the rest of this turn. End your turn now; if you have ' +
|
|
419
|
+
'something to say, send it on the next turn.'
|
|
420
|
+
|
|
372
421
|
export type ChannelRouter = {
|
|
373
422
|
route: (event: InboundMessage) => Promise<void>
|
|
374
423
|
send: (msg: OutboundMessage, opts?: SendOptions) => Promise<SendResult>
|
|
@@ -434,6 +483,31 @@ export type ChannelRouter = {
|
|
|
434
483
|
durationMs: number
|
|
435
484
|
error?: string
|
|
436
485
|
}) => { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' }
|
|
486
|
+
// Record that the agent invoked `skip_response` during the current turn
|
|
487
|
+
// for the channel session identified by `parentSessionId`. The reason is
|
|
488
|
+
// logged at INFO level inside `validateChannelTurn` (single log line per
|
|
489
|
+
// skip, so operators see exactly one record per silent turn). Stamps the
|
|
490
|
+
// current `turnSeq` on the live session so a stale record from an earlier
|
|
491
|
+
// turn cannot drop a future legitimate reply.
|
|
492
|
+
//
|
|
493
|
+
// Returns:
|
|
494
|
+
// - 'recorded' — the live session was found and the skip was stamped
|
|
495
|
+
// - 'send-already-happened' — a tool-source channel send already landed
|
|
496
|
+
// in this turn; the skip is refused (symmetric with
|
|
497
|
+
// the send-after-skip lock in `send()`) so the model
|
|
498
|
+
// cannot land a reply AND claim silence. The flag is
|
|
499
|
+
// NOT stamped, so the turn proceeds as a normal
|
|
500
|
+
// reply turn.
|
|
501
|
+
// - 'no-live-session' — no matching channel session (e.g. tool fired
|
|
502
|
+
// outside a channel origin); the tool should
|
|
503
|
+
// still log the reason but cannot suppress.
|
|
504
|
+
markTurnSkipped: (args: {
|
|
505
|
+
parentSessionId: string
|
|
506
|
+
reason: string
|
|
507
|
+
}) =>
|
|
508
|
+
| { kind: 'recorded'; keyId: string }
|
|
509
|
+
| { kind: 'send-already-happened'; keyId: string }
|
|
510
|
+
| { kind: 'no-live-session' }
|
|
437
511
|
stop: () => Promise<void>
|
|
438
512
|
liveCount: () => number
|
|
439
513
|
__testing?: {
|
|
@@ -926,6 +1000,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
926
1000
|
lastSentText: new Map(),
|
|
927
1001
|
sendTimestamps: new Map(),
|
|
928
1002
|
successfulChannelSends: 0,
|
|
1003
|
+
turnSeq: 0,
|
|
1004
|
+
successfulSendsAtTurnStart: 0,
|
|
1005
|
+
skippedTurn: null,
|
|
929
1006
|
pendingQuoteCandidate: null,
|
|
930
1007
|
recentEngagedPeerBotTurns: [],
|
|
931
1008
|
consecutiveEngagedPeerBotTurns: 0,
|
|
@@ -933,10 +1010,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
933
1010
|
membershipFetch,
|
|
934
1011
|
destroyed: false,
|
|
935
1012
|
unsubProviderErrors: null,
|
|
1013
|
+
unsubTypingActivity: null,
|
|
936
1014
|
}
|
|
937
1015
|
live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
|
|
938
1016
|
logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
|
|
939
1017
|
})
|
|
1018
|
+
live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
|
|
940
1019
|
liveSessions.set(keyId, live)
|
|
941
1020
|
|
|
942
1021
|
if (isColdStart) {
|
|
@@ -1019,6 +1098,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1019
1098
|
authorIsBot: item.message.isBot,
|
|
1020
1099
|
receivedAt: now(),
|
|
1021
1100
|
ts: item.message.ts,
|
|
1101
|
+
source: 'prefetch',
|
|
1022
1102
|
})
|
|
1023
1103
|
} else {
|
|
1024
1104
|
observed.push({
|
|
@@ -1028,6 +1108,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1028
1108
|
authorIsBot: true,
|
|
1029
1109
|
receivedAt: now(),
|
|
1030
1110
|
ts: 0,
|
|
1111
|
+
source: 'prefetch',
|
|
1031
1112
|
})
|
|
1032
1113
|
}
|
|
1033
1114
|
}
|
|
@@ -1080,9 +1161,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1080
1161
|
)
|
|
1081
1162
|
}
|
|
1082
1163
|
|
|
1164
|
+
const bumpTypingActivity = (live: LiveSession): void => {
|
|
1165
|
+
if (live.typingTimer === null) return
|
|
1166
|
+
live.typingStartedAt = now()
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const subscribeTypingActivity = (session: AgentSession, live: LiveSession): (() => void) => {
|
|
1170
|
+
return session.subscribe((event) => {
|
|
1171
|
+
if (event.type !== 'tool_execution_end') return
|
|
1172
|
+
bumpTypingActivity(live)
|
|
1173
|
+
})
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1083
1176
|
const startTypingHeartbeat = (live: LiveSession): void => {
|
|
1084
1177
|
if (live.typingTimedOut || live.typingStopPromise) return
|
|
1085
|
-
if (live.
|
|
1178
|
+
if (live.destroyed) return
|
|
1179
|
+
if (live.typingTimer) {
|
|
1180
|
+
bumpTypingActivity(live)
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1086
1183
|
live.typingStartedAt = now()
|
|
1087
1184
|
// Fire immediately so the indicator appears on the very first inbound,
|
|
1088
1185
|
// not 8 seconds later.
|
|
@@ -1093,7 +1190,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1093
1190
|
return
|
|
1094
1191
|
}
|
|
1095
1192
|
if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
|
|
1096
|
-
logger.warn(
|
|
1193
|
+
logger.warn(
|
|
1194
|
+
`[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms with no activity; prompt still in flight`,
|
|
1195
|
+
)
|
|
1097
1196
|
live.typingTimedOut = true
|
|
1098
1197
|
void stopTypingHeartbeat(live)
|
|
1099
1198
|
return
|
|
@@ -1218,6 +1317,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1218
1317
|
const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
|
|
1219
1318
|
const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
|
|
1220
1319
|
const text = composeTurnPrompt(observed, batch, {
|
|
1320
|
+
adapter: live.key.adapter,
|
|
1221
1321
|
loopGuardActive: live.loopGuardActive,
|
|
1222
1322
|
systemReminders: reminders,
|
|
1223
1323
|
})
|
|
@@ -1227,7 +1327,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1227
1327
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1228
1328
|
live.consecutiveSends.clear()
|
|
1229
1329
|
live.lastSentText.clear()
|
|
1230
|
-
live.pendingQuoteCandidate = captureQuoteCandidate(batch, observed)
|
|
1330
|
+
live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
|
|
1231
1331
|
} else if (live.lastTurnAuthorId !== null) {
|
|
1232
1332
|
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
1233
1333
|
// restore the author identity from the prior turn so author-
|
|
@@ -1259,6 +1359,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1259
1359
|
logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
|
|
1260
1360
|
const promptStart = now()
|
|
1261
1361
|
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
1362
|
+
live.turnSeq++
|
|
1363
|
+
live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
|
|
1262
1364
|
await fireSessionTurnStart(live, text)
|
|
1263
1365
|
try {
|
|
1264
1366
|
await live.session.prompt(text)
|
|
@@ -1538,6 +1640,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1538
1640
|
authorIsBot: event.authorIsBot,
|
|
1539
1641
|
receivedAt: now(),
|
|
1540
1642
|
ts: event.ts,
|
|
1643
|
+
source: 'observed',
|
|
1541
1644
|
})
|
|
1542
1645
|
if (live.contextBuffer.length > CONTEXT_BUFFER_SIZE) {
|
|
1543
1646
|
live.contextBuffer.splice(0, live.contextBuffer.length - CONTEXT_BUFFER_SIZE)
|
|
@@ -1711,17 +1814,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1711
1814
|
const live = liveSessions.get(keyId)
|
|
1712
1815
|
const sendKey = consecutiveSendKey(msg.chat, msg.thread)
|
|
1713
1816
|
// Tool-source sends consume the captured quote candidate exactly
|
|
1714
|
-
// once per turn — the
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1817
|
+
// once per turn — the intervening-observed check runs HERE against
|
|
1818
|
+
// the live buffer so the relevant signal is actual channel chatter
|
|
1819
|
+
// between inbound and reply landing, not drain-vs-send timing
|
|
1820
|
+
// artifacts. System sources (recovery, role-
|
|
1718
1821
|
// claim) skip so they can't accidentally swallow the candidate
|
|
1719
1822
|
// before the model's own first reply lands. Even when the decision
|
|
1720
|
-
// returns null (
|
|
1721
|
-
//
|
|
1722
|
-
// threshold mid-flight must not retroactively anchor chunk 2.
|
|
1823
|
+
// returns null (nothing intervened), the candidate is cleared — a
|
|
1824
|
+
// multi-part reply must not retroactively anchor chunk 2.
|
|
1723
1825
|
if (live && source === 'tool' && live.pendingQuoteCandidate !== null) {
|
|
1724
|
-
const
|
|
1826
|
+
const quoteCandidate = refreshQuoteCandidate(live.pendingQuoteCandidate, live.contextBuffer)
|
|
1827
|
+
const anchor = decideQuoteAnchor(quoteCandidate, now(), options.configForAdapter(msg.adapter))
|
|
1725
1828
|
if (anchor !== null) msg = { ...msg, text: prependQuoteAnchor(msg.text ?? '', anchor) }
|
|
1726
1829
|
live.pendingQuoteCandidate = null
|
|
1727
1830
|
}
|
|
@@ -1739,6 +1842,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1739
1842
|
let priorLastSentText: string | undefined
|
|
1740
1843
|
let reserved = false
|
|
1741
1844
|
if (live && source === 'tool') {
|
|
1845
|
+
// Tool-source send after `skip_response` for the same turn is a contract
|
|
1846
|
+
// violation: the model already committed to silence. Reject before any
|
|
1847
|
+
// state mutation so the model gets a clear error and the channel stays
|
|
1848
|
+
// silent. System-source sends (recovery, role-claim) are not affected.
|
|
1849
|
+
if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
|
|
1850
|
+
return { ok: false, error: SKIP_RESPONSE_LOCK_ERROR, code: 'skip-locked' }
|
|
1851
|
+
}
|
|
1742
1852
|
const currentCount = live.consecutiveSends.get(sendKey) ?? 0
|
|
1743
1853
|
if (currentCount >= MAX_CHANNEL_SENDS_PER_TURN) {
|
|
1744
1854
|
return { ok: false, error: TURN_CAP_ERROR, code: 'turn-cap' }
|
|
@@ -1825,6 +1935,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1825
1935
|
}
|
|
1826
1936
|
|
|
1827
1937
|
const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
|
|
1938
|
+
// `skip_response` short-circuit. Must run before the `successfulChannelSends`
|
|
1939
|
+
// check so a model that called both `skip_response` and a channel tool in
|
|
1940
|
+
// the same turn still resolves as "skipped" — the rejection inside `send()`
|
|
1941
|
+
// means the channel tool returned an error and never actually delivered.
|
|
1942
|
+
// Stale-flag protection: only honor when stamped on the just-completed
|
|
1943
|
+
// turn. A flag set by a previous turn that crashed before validation
|
|
1944
|
+
// would otherwise drop the next legitimate user-facing reply.
|
|
1945
|
+
if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
|
|
1946
|
+
const { reason } = live.skippedTurn
|
|
1947
|
+
live.skippedTurn = null
|
|
1948
|
+
logger.info(`[channels] ${live.keyId} skipped_by_tool reason=${JSON.stringify(reason)}`)
|
|
1949
|
+
return
|
|
1950
|
+
}
|
|
1828
1951
|
if (live.successfulChannelSends > successfulSendsBeforePrompt) return
|
|
1829
1952
|
|
|
1830
1953
|
const candidate = recoverableAssistantText(live.session)
|
|
@@ -1934,6 +2057,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1934
2057
|
live.debounceTimer = null
|
|
1935
2058
|
live.unsubProviderErrors?.()
|
|
1936
2059
|
live.unsubProviderErrors = null
|
|
2060
|
+
live.unsubTypingActivity?.()
|
|
2061
|
+
live.unsubTypingActivity = null
|
|
1937
2062
|
await stopTypingHeartbeat(live)
|
|
1938
2063
|
try {
|
|
1939
2064
|
await live.session.abort()
|
|
@@ -2071,6 +2196,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2071
2196
|
return { kind: 'no-live-session' }
|
|
2072
2197
|
}
|
|
2073
2198
|
|
|
2199
|
+
const markTurnSkipped = (args: {
|
|
2200
|
+
parentSessionId: string
|
|
2201
|
+
reason: string
|
|
2202
|
+
}):
|
|
2203
|
+
| { kind: 'recorded'; keyId: string }
|
|
2204
|
+
| { kind: 'send-already-happened'; keyId: string }
|
|
2205
|
+
| { kind: 'no-live-session' } => {
|
|
2206
|
+
for (const live of liveSessions.values()) {
|
|
2207
|
+
if (live.destroyed) continue
|
|
2208
|
+
if (live.sessionId !== args.parentSessionId) continue
|
|
2209
|
+
if (live.successfulChannelSends > live.successfulSendsAtTurnStart) {
|
|
2210
|
+
return { kind: 'send-already-happened', keyId: live.keyId }
|
|
2211
|
+
}
|
|
2212
|
+
live.skippedTurn = { turnSeq: live.turnSeq, reason: args.reason }
|
|
2213
|
+
return { kind: 'recorded', keyId: live.keyId }
|
|
2214
|
+
}
|
|
2215
|
+
return { kind: 'no-live-session' }
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2074
2218
|
return {
|
|
2075
2219
|
route,
|
|
2076
2220
|
send,
|
|
@@ -2093,6 +2237,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2093
2237
|
executeCommand,
|
|
2094
2238
|
getSelfAliases: computeSelfAliases,
|
|
2095
2239
|
injectSubagentCompletionReminder,
|
|
2240
|
+
markTurnSkipped,
|
|
2096
2241
|
stop,
|
|
2097
2242
|
liveCount: () => liveSessions.size,
|
|
2098
2243
|
__testing: {
|
|
@@ -2119,7 +2264,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2119
2264
|
return
|
|
2120
2265
|
}
|
|
2121
2266
|
if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
|
|
2122
|
-
logger.warn(
|
|
2267
|
+
logger.warn(
|
|
2268
|
+
`[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms with no activity; prompt still in flight`,
|
|
2269
|
+
)
|
|
2123
2270
|
live.typingTimedOut = true
|
|
2124
2271
|
await stopTypingHeartbeat(live)
|
|
2125
2272
|
return
|
|
@@ -2159,8 +2306,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2159
2306
|
function composeTurnPrompt(
|
|
2160
2307
|
observed: readonly ObservedInbound[],
|
|
2161
2308
|
batch: readonly QueuedInbound[],
|
|
2162
|
-
state: { loopGuardActive: boolean; systemReminders?: readonly string[] } = {
|
|
2309
|
+
state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[] } = {
|
|
2310
|
+
loopGuardActive: false,
|
|
2311
|
+
},
|
|
2163
2312
|
): string {
|
|
2313
|
+
const adapter = state.adapter ?? 'discord-bot'
|
|
2164
2314
|
const parts: string[] = []
|
|
2165
2315
|
// System reminders (subagent-completion wakeups today) lead the turn body
|
|
2166
2316
|
// because they are typically what triggered the drain — when the prompt
|
|
@@ -2226,7 +2376,7 @@ function composeTurnPrompt(
|
|
|
2226
2376
|
if (observed.length > 0) {
|
|
2227
2377
|
parts.push('## Recent context (not addressed to you, for awareness only)')
|
|
2228
2378
|
for (const o of observed) {
|
|
2229
|
-
parts.push(formatAuthorLine(o.ts, o.authorId, o.authorName, o.authorIsBot, o.text))
|
|
2379
|
+
parts.push(formatAuthorLine(o.ts, adapter, o.authorId, o.authorName, o.authorIsBot, o.text))
|
|
2230
2380
|
}
|
|
2231
2381
|
parts.push('')
|
|
2232
2382
|
}
|
|
@@ -2244,7 +2394,7 @@ function composeTurnPrompt(
|
|
|
2244
2394
|
)
|
|
2245
2395
|
}
|
|
2246
2396
|
for (const b of batch) {
|
|
2247
|
-
parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
|
|
2397
|
+
parts.push(formatAuthorLine(b.ts, adapter, b.authorId, b.authorName, b.authorIsBot, b.text))
|
|
2248
2398
|
}
|
|
2249
2399
|
}
|
|
2250
2400
|
return parts.join('\n')
|
|
@@ -2252,6 +2402,7 @@ function composeTurnPrompt(
|
|
|
2252
2402
|
|
|
2253
2403
|
function formatAuthorLine(
|
|
2254
2404
|
ts: number,
|
|
2405
|
+
adapter: AdapterId,
|
|
2255
2406
|
authorId: string,
|
|
2256
2407
|
authorName: string,
|
|
2257
2408
|
authorIsBot: boolean,
|
|
@@ -2259,15 +2410,44 @@ function formatAuthorLine(
|
|
|
2259
2410
|
): string {
|
|
2260
2411
|
const tag = authorIsBot ? ' [bot]' : ''
|
|
2261
2412
|
const stamp = ts > 0 ? `[${new Date(ts).toISOString()}] ` : ''
|
|
2262
|
-
return `${stamp}
|
|
2413
|
+
return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
|
|
2263
2414
|
}
|
|
2264
2415
|
|
|
2265
2416
|
export type QuoteAnchorSource = {
|
|
2417
|
+
adapter: AdapterId
|
|
2418
|
+
authorId: string
|
|
2266
2419
|
authorName: string
|
|
2267
2420
|
text: string
|
|
2268
2421
|
}
|
|
2269
2422
|
|
|
2270
|
-
//
|
|
2423
|
+
// Picks the right author syntax for the platform so prompts and rendered
|
|
2424
|
+
// quote anchors use the same form the user would type in that channel.
|
|
2425
|
+
// Slack/Discord need id mentions (`<@U…>`), GitHub needs handle mentions
|
|
2426
|
+
// (`@login`) because inbound author ids are numeric, and adapters without
|
|
2427
|
+
// stable id-only mention syntax fall back to plain display names.
|
|
2428
|
+
//
|
|
2429
|
+
// Notification semantics: Slack and Discord both render `<@…>` as a
|
|
2430
|
+
// styled mention link inside blockquotes; whether the mentioned user is
|
|
2431
|
+
// PINGED is a separate platform-level UX (Slack pings on first appearance
|
|
2432
|
+
// in the message regardless of position, Discord respects the
|
|
2433
|
+
// `allowed_mentions` field which defaults to "ping everyone parsed").
|
|
2434
|
+
// This matches PR #374's intent — the user IS being notified that the
|
|
2435
|
+
// agent replied to them, which is the whole point of a quote anchor.
|
|
2436
|
+
function formatAuthorReference(adapter: AdapterId, authorId: string, authorName: string): string {
|
|
2437
|
+
const displayName = authorName.trim() !== '' ? authorName.trim() : authorId
|
|
2438
|
+
switch (adapter) {
|
|
2439
|
+
case 'slack-bot':
|
|
2440
|
+
case 'discord-bot':
|
|
2441
|
+
return `<@${authorId}>`
|
|
2442
|
+
case 'github':
|
|
2443
|
+
return displayName.startsWith('@') ? displayName : `@${displayName}`
|
|
2444
|
+
case 'telegram-bot':
|
|
2445
|
+
case 'kakaotalk':
|
|
2446
|
+
return displayName
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// Renders the single-line `> @mention: excerpt` blockquote prepended to
|
|
2271
2451
|
// outbound replies when the router decides the reply needs an anchor.
|
|
2272
2452
|
// Collapses newlines to spaces so a multi-line user message renders on
|
|
2273
2453
|
// one quoted line (markdown blockquote semantics: a blank line ends the
|
|
@@ -2286,17 +2466,27 @@ export function renderQuoteAnchor(source: QuoteAnchorSource): string {
|
|
|
2286
2466
|
: collapsed.length > QUOTED_REPLY_EXCERPT_MAX_CHARS
|
|
2287
2467
|
? `${collapsed.slice(0, QUOTED_REPLY_EXCERPT_MAX_CHARS - 1)}…`
|
|
2288
2468
|
: collapsed
|
|
2289
|
-
|
|
2469
|
+
const mention = formatAuthorReference(source.adapter, source.authorId, source.authorName)
|
|
2470
|
+
return `> ${mention}: ${excerpt}`
|
|
2290
2471
|
}
|
|
2291
2472
|
|
|
2473
|
+
// Separates the anchor from the reply with a blank line (`\n\n`), not a
|
|
2474
|
+
// single `\n`. In standard GFM and Slack's `markdown` block, a single
|
|
2475
|
+
// `\n` inside a paragraph is a soft break rendered as whitespace, which
|
|
2476
|
+
// keeps the `>` blockquote styling running visually through the next
|
|
2477
|
+
// line — i.e. the agent's reply text gets swallowed into the quote. The
|
|
2478
|
+
// blank line forces a paragraph boundary that unambiguously ends the
|
|
2479
|
+
// blockquote on every renderer (CommonMark, GFM, Slack mrkdwn, Discord
|
|
2480
|
+
// markdown).
|
|
2292
2481
|
export function prependQuoteAnchor(replyText: string, source: QuoteAnchorSource): string {
|
|
2293
2482
|
const anchor = renderQuoteAnchor(source)
|
|
2294
2483
|
if (replyText === '') return anchor
|
|
2295
|
-
return `${anchor}\n${replyText}`
|
|
2484
|
+
return `${anchor}\n\n${replyText}`
|
|
2296
2485
|
}
|
|
2297
2486
|
|
|
2298
2487
|
type QuoteAnchorBatchEntry = {
|
|
2299
2488
|
text: string
|
|
2489
|
+
authorId: string
|
|
2300
2490
|
authorName: string
|
|
2301
2491
|
authorIsBot: boolean
|
|
2302
2492
|
receivedAt: number
|
|
@@ -2304,6 +2494,7 @@ type QuoteAnchorBatchEntry = {
|
|
|
2304
2494
|
|
|
2305
2495
|
type QuoteAnchorObservedEntry = {
|
|
2306
2496
|
receivedAt: number
|
|
2497
|
+
source: 'prefetch' | 'observed'
|
|
2307
2498
|
}
|
|
2308
2499
|
|
|
2309
2500
|
export type QuoteAnchorCandidate = {
|
|
@@ -2312,42 +2503,86 @@ export type QuoteAnchorCandidate = {
|
|
|
2312
2503
|
hadInterveningObserved: boolean
|
|
2313
2504
|
}
|
|
2314
2505
|
|
|
2506
|
+
// Strips `[<Adapter> message with ...]` placeholders that adapter
|
|
2507
|
+
// classifiers synthesize for non-text inbounds (KakaoTalk stickers,
|
|
2508
|
+
// Slack/Discord/Telegram attachments). The quote anchor is a UX
|
|
2509
|
+
// affordance pointing the human at *their words* — quoting a sticker as
|
|
2510
|
+
// `> Alice: [KakaoTalk message with sticker (sticker_ani) pack=... path=...]`
|
|
2511
|
+
// is noise, and for mixed inbounds like `사진 [KakaoTalk message with
|
|
2512
|
+
// photo 1254x1254 ...]` the human only wrote `사진`, so the placeholder
|
|
2513
|
+
// is the wrong thing to surface. The callsite (captureQuoteCandidate)
|
|
2514
|
+
// treats an empty residue as "no quote anchor"; mixed inbounds keep the
|
|
2515
|
+
// human-written portion. renderQuoteAnchor later collapses whitespace
|
|
2516
|
+
// so residual double-spaces from mid-string strips are harmless.
|
|
2517
|
+
const CHANNEL_MEDIA_PLACEHOLDER_RE = /\[(?:KakaoTalk|Slack|Discord|Telegram) message with [^\]]*\]/g
|
|
2518
|
+
|
|
2519
|
+
export function stripChannelMediaPlaceholders(text: string): string {
|
|
2520
|
+
return text
|
|
2521
|
+
.replace(CHANNEL_MEDIA_PLACEHOLDER_RE, '')
|
|
2522
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
2523
|
+
.trim()
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2315
2526
|
// Snapshot the primary inbound + observed-buffer state at drain time so
|
|
2316
2527
|
// the send-side decision has the data it needs without holding a
|
|
2317
2528
|
// reference to the batch arrays. Returns null when there's nothing
|
|
2318
|
-
// anchorable (empty batch, primary is a bot
|
|
2529
|
+
// anchorable (empty batch, primary is a bot, or primary is a non-text
|
|
2530
|
+
// inbound with no residual human-written text after stripping the
|
|
2531
|
+
// adapter's media placeholder).
|
|
2532
|
+
//
|
|
2533
|
+
// `hadInterveningObserved` counts ONLY live observations (`source ===
|
|
2534
|
+
// 'observed'`), not prefetched scrollback. Prefetch stamps `receivedAt =
|
|
2535
|
+
// now()` inside ensureLive — wall-clock-later than the primary inbound
|
|
2536
|
+
// that triggered ensureLive — so without this gate, every cold-start
|
|
2537
|
+
// first turn would see "intervening observed" entries and fire the
|
|
2538
|
+
// quote anchor even when the reply lands within milliseconds. The
|
|
2539
|
+
// signal we actually want is "did real new chatter arrive between the
|
|
2540
|
+
// user's inbound and the agent's reply", which only live observations
|
|
2541
|
+
// represent.
|
|
2319
2542
|
export function captureQuoteCandidate(
|
|
2543
|
+
adapter: AdapterId,
|
|
2320
2544
|
batch: readonly QuoteAnchorBatchEntry[],
|
|
2321
2545
|
observed: readonly QuoteAnchorObservedEntry[],
|
|
2322
2546
|
): QuoteAnchorCandidate | null {
|
|
2323
2547
|
if (batch.length === 0) return null
|
|
2324
2548
|
const primary = batch[batch.length - 1]!
|
|
2325
2549
|
if (primary.authorIsBot) return null
|
|
2326
|
-
const
|
|
2550
|
+
const cleaned = stripChannelMediaPlaceholders(primary.text)
|
|
2551
|
+
if (cleaned === '') return null
|
|
2327
2552
|
return {
|
|
2328
|
-
source: {
|
|
2553
|
+
source: { adapter, authorId: primary.authorId, authorName: primary.authorName, text: cleaned },
|
|
2329
2554
|
primaryReceivedAt: primary.receivedAt,
|
|
2330
|
-
hadInterveningObserved,
|
|
2555
|
+
hadInterveningObserved: hasInterveningObserved(primary.receivedAt, observed),
|
|
2331
2556
|
}
|
|
2332
2557
|
}
|
|
2333
2558
|
|
|
2559
|
+
function refreshQuoteCandidate(
|
|
2560
|
+
candidate: QuoteAnchorCandidate,
|
|
2561
|
+
observed: readonly QuoteAnchorObservedEntry[],
|
|
2562
|
+
): QuoteAnchorCandidate {
|
|
2563
|
+
if (candidate.hadInterveningObserved) return candidate
|
|
2564
|
+
if (!hasInterveningObserved(candidate.primaryReceivedAt, observed)) return candidate
|
|
2565
|
+
return { ...candidate, hadInterveningObserved: true }
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
function hasInterveningObserved(primaryReceivedAt: number, observed: readonly QuoteAnchorObservedEntry[]): boolean {
|
|
2569
|
+
return observed.some((o) => o.source === 'observed' && o.receivedAt >= primaryReceivedAt)
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2334
2572
|
// Send-time decision: given a captured candidate and the current clock,
|
|
2335
2573
|
// returns the source to anchor against or null. Skips when:
|
|
2336
2574
|
// - quotedReply is disabled in config
|
|
2337
|
-
// -
|
|
2338
|
-
// primary inbound and now (the "felt instantaneous" path)
|
|
2575
|
+
// - no observed messages came between primary inbound and now
|
|
2339
2576
|
// A null candidate (no batch yet, or batch was bot-only) always skips.
|
|
2340
2577
|
export function decideQuoteAnchor(
|
|
2341
2578
|
candidate: QuoteAnchorCandidate | null,
|
|
2342
|
-
|
|
2579
|
+
_nowMs: number,
|
|
2343
2580
|
adapterConfig: ChannelAdapterConfig | undefined,
|
|
2344
2581
|
): QuoteAnchorSource | null {
|
|
2345
2582
|
if (candidate === null) return null
|
|
2346
2583
|
const config = adapterConfig?.quotedReply
|
|
2347
2584
|
if (config !== undefined && config.enabled === false) return null
|
|
2348
|
-
|
|
2349
|
-
const delay = nowMs - candidate.primaryReceivedAt
|
|
2350
|
-
if (delay < threshold && !candidate.hadInterveningObserved) return null
|
|
2585
|
+
if (!candidate.hadInterveningObserved) return null
|
|
2351
2586
|
return candidate.source
|
|
2352
2587
|
}
|
|
2353
2588
|
|
package/src/channels/schema.ts
CHANGED
|
@@ -87,13 +87,12 @@ const historySchema = z
|
|
|
87
87
|
},
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// between the inbound and the reply, the router
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// reads as real-time and needs no anchor.
|
|
90
|
+
// Legacy quote-delay knob retained for config compatibility. Quote anchors
|
|
91
|
+
// are now driven by channel ordering instead: when another live-observed
|
|
92
|
+
// message lands between the inbound and the agent's first reply, the router
|
|
93
|
+
// prepends a platform-specific `> author: ...` blockquote line referencing
|
|
94
|
+
// the inbound. If nothing intervened, the reply is adjacent enough in the
|
|
95
|
+
// channel timeline that it needs no anchor regardless of elapsed wall time.
|
|
97
96
|
export const DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS = 10_000
|
|
98
97
|
|
|
99
98
|
// Long enough to disambiguate; short enough that a multi-paragraph user
|
|
@@ -126,6 +125,8 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
|
126
125
|
'discussion_comment.created',
|
|
127
126
|
'issues.opened',
|
|
128
127
|
'pull_request.opened',
|
|
128
|
+
'pull_request.review_requested',
|
|
129
|
+
'pull_request.review_request_removed',
|
|
129
130
|
'discussion.created',
|
|
130
131
|
'pull_request_review.submitted',
|
|
131
132
|
] as const
|
package/src/channels/types.ts
CHANGED
|
@@ -84,7 +84,7 @@ export type OutboundMessage = {
|
|
|
84
84
|
attachments?: OutboundAttachment[]
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export type SendErrorCode = 'duplicate' | 'turn-cap' | 'no-adapter' | 'callback-rejected'
|
|
87
|
+
export type SendErrorCode = 'duplicate' | 'turn-cap' | 'no-adapter' | 'callback-rejected' | 'skip-locked'
|
|
88
88
|
|
|
89
89
|
export type SendResult = { ok: true } | { ok: false; error: string; code?: SendErrorCode }
|
|
90
90
|
|