typeclaw 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/index.ts +7 -0
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +265 -22
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +79 -0
- package/src/channels/adapters/github/index.ts +19 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +276 -0
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +25 -2
- package/src/channels/engagement.ts +81 -44
- package/src/channels/router.ts +255 -18
- package/src/channels/types.ts +57 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +147 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/inspect.ts +3 -0
- package/src/dreams/git.ts +85 -0
- package/src/dreams/index.ts +134 -0
- package/src/dreams/parse.ts +224 -0
- package/src/dreams/render.ts +155 -0
- package/src/dreams/types.ts +50 -0
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/server/index.ts +49 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
- package/src/tui/index.ts +70 -18
package/src/channels/router.ts
CHANGED
|
@@ -51,9 +51,15 @@ import type {
|
|
|
51
51
|
HistoryCallback,
|
|
52
52
|
InboundAttachment,
|
|
53
53
|
InboundMessage,
|
|
54
|
+
RemoveReactionCallback,
|
|
55
|
+
RemoveReactionRequest,
|
|
54
56
|
OutboundCallback,
|
|
55
57
|
OutboundMessage,
|
|
56
58
|
QuoteAnchorSource,
|
|
59
|
+
ReactionCallback,
|
|
60
|
+
ReactionRef,
|
|
61
|
+
ReactionRequest,
|
|
62
|
+
ReactionResult,
|
|
57
63
|
ResolvedChannelNames,
|
|
58
64
|
SendErrorCode,
|
|
59
65
|
SendResult,
|
|
@@ -108,6 +114,7 @@ export const SESSION_GC_INTERVAL_MS = 60 * 1000
|
|
|
108
114
|
// Enforced inside router.send for `source: 'tool'` callers; system
|
|
109
115
|
// recovery paths (`source: 'system'`) bypass.
|
|
110
116
|
export const MAX_CHANNEL_SENDS_PER_TURN = 10
|
|
117
|
+
export const ENGAGE_REACTION_EMOJI = 'eyes'
|
|
111
118
|
// Ceiling on tool-source channel sends that a same-turn router policy DENIED
|
|
112
119
|
// without delivering — `skip-locked`, `turn-cap`, or `duplicate`. Such denials
|
|
113
120
|
// return a soft error and do NOT increment `consecutiveSends`, so a model that
|
|
@@ -276,6 +283,8 @@ type QueuedInbound = {
|
|
|
276
283
|
authorName: string
|
|
277
284
|
authorIsBot: boolean
|
|
278
285
|
externalMessageId: string
|
|
286
|
+
reactionRef?: ReactionRef
|
|
287
|
+
engageReaction?: Promise<ReactionRef | null>
|
|
279
288
|
isBotMention: boolean
|
|
280
289
|
replyToBotMessageId: string | null
|
|
281
290
|
isDm: boolean
|
|
@@ -324,6 +333,14 @@ type LiveSession = {
|
|
|
324
333
|
originRef: { current: SessionOrigin | undefined }
|
|
325
334
|
promptQueue: QueuedInbound[]
|
|
326
335
|
contextBuffer: ObservedInbound[]
|
|
336
|
+
// Attachments of the messages composing the in-flight turn. drain()
|
|
337
|
+
// splices promptQueue/contextBuffer empty BEFORE calling prompt(), but
|
|
338
|
+
// the model only requests an attachment (look_at_channel_attachment /
|
|
339
|
+
// channel_fetch_attachment) DURING prompt() — by which point both queues
|
|
340
|
+
// are empty. This turn-scoped snapshot, populated right after the splice
|
|
341
|
+
// and cleared when the turn ends, is what the lookup reads so a freshly-
|
|
342
|
+
// arrived attachment stays resolvable for the whole turn it belongs to.
|
|
343
|
+
currentTurnAttachments: readonly InboundAttachment[]
|
|
327
344
|
draining: boolean
|
|
328
345
|
debounceTimer: ReturnType<typeof setTimeout> | null
|
|
329
346
|
typingTimer: ReturnType<typeof setInterval> | null
|
|
@@ -334,6 +351,16 @@ type LiveSession = {
|
|
|
334
351
|
firstUnprocessedAt: number
|
|
335
352
|
currentTurnAuthorId: string | null
|
|
336
353
|
currentTurnAuthorIds: Set<string>
|
|
354
|
+
// Reaction target of the inbound that triggered THIS turn (the last item in
|
|
355
|
+
// the drained batch, mirroring `currentTurnAuthorId`). Surfaced on the live
|
|
356
|
+
// origin so `channel_react` reacts to the triggering message, not whichever
|
|
357
|
+
// inbound happens to be latest in the queue. Null on reminder-only turns.
|
|
358
|
+
currentTurnReactionRef: ReactionRef | null
|
|
359
|
+
// One engage-:eyes:-add promise per inbound coalesced into THIS turn, each
|
|
360
|
+
// resolving to its removable per-instance ref (or null). A debounced turn can
|
|
361
|
+
// batch several inbounds that each got their own :eyes:, so every entry is
|
|
362
|
+
// removed after the reply. Empty on turns with no reactable inbound.
|
|
363
|
+
currentTurnEngageReactions: Array<Promise<ReactionRef | null>>
|
|
337
364
|
lastTurnAuthorIds: Set<string>
|
|
338
365
|
// Mirror of currentTurnAuthorId at end-of-turn (the LAST speaker of the
|
|
339
366
|
// prior batch), preserved across the drain finally-block which resets
|
|
@@ -511,6 +538,17 @@ export type ChannelRouter = {
|
|
|
511
538
|
}) => { count: number; windowMs: number }
|
|
512
539
|
registerOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
|
|
513
540
|
unregisterOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
|
|
541
|
+
// Reaction support is opt-in per adapter: an adapter that never calls
|
|
542
|
+
// registerReaction makes `react` resolve to `code: 'unsupported'`, and
|
|
543
|
+
// auto-react-on-engage becomes a silent no-op for it. Kept separate from
|
|
544
|
+
// the outbound path on purpose — reactions are best-effort side effects, not
|
|
545
|
+
// messages, so they must not flow through send()'s flood/cap/dup/sticky guards.
|
|
546
|
+
registerReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
|
|
547
|
+
unregisterReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
|
|
548
|
+
react: (req: ReactionRequest) => Promise<ReactionResult>
|
|
549
|
+
registerRemoveReaction: (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback) => void
|
|
550
|
+
unregisterRemoveReaction: (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback) => void
|
|
551
|
+
removeReaction: (req: RemoveReactionRequest) => Promise<ReactionResult>
|
|
514
552
|
registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
515
553
|
unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
516
554
|
registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
|
|
@@ -703,6 +741,7 @@ export type ClaimHandler = (input: ClaimHandlerInput) => Promise<ClaimHandlerOut
|
|
|
703
741
|
const GRANT_ALL_PERMISSIONS: PermissionService = {
|
|
704
742
|
has: () => true,
|
|
705
743
|
resolveRole: () => 'owner',
|
|
744
|
+
compareRoleSeverity: () => 1,
|
|
706
745
|
describe: () => ({ role: 'owner', permissions: [CORE_PERMISSIONS.channelRespond] }),
|
|
707
746
|
replaceRoles: () => {},
|
|
708
747
|
}
|
|
@@ -728,6 +767,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
728
767
|
// teardown was meant to clear.
|
|
729
768
|
let liveGeneration = 0
|
|
730
769
|
const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
|
|
770
|
+
const reactionCallbacks = new Map<ChannelKey['adapter'], Set<ReactionCallback>>()
|
|
771
|
+
const removeReactionCallbacks = new Map<ChannelKey['adapter'], Set<RemoveReactionCallback>>()
|
|
731
772
|
const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
|
|
732
773
|
const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
|
|
733
774
|
const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
|
|
@@ -1081,6 +1122,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1081
1122
|
promptQueue: [],
|
|
1082
1123
|
pendingSystemReminders: [],
|
|
1083
1124
|
contextBuffer: [],
|
|
1125
|
+
currentTurnAttachments: [],
|
|
1084
1126
|
draining: false,
|
|
1085
1127
|
debounceTimer: null,
|
|
1086
1128
|
typingTimer: null,
|
|
@@ -1091,6 +1133,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1091
1133
|
firstUnprocessedAt: 0,
|
|
1092
1134
|
currentTurnAuthorId: null,
|
|
1093
1135
|
currentTurnAuthorIds: new Set(),
|
|
1136
|
+
currentTurnReactionRef: null,
|
|
1137
|
+
currentTurnEngageReactions: [],
|
|
1094
1138
|
// `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
|
|
1095
1139
|
// origin) and `lastTurnAuthorIds` (Set, used by
|
|
1096
1140
|
// `grantStickyForReplyTargets` as the fallback when
|
|
@@ -1223,6 +1267,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1223
1267
|
receivedAt: now(),
|
|
1224
1268
|
ts: item.message.ts,
|
|
1225
1269
|
source: 'prefetch',
|
|
1270
|
+
...(item.message.attachments !== undefined ? { attachments: item.message.attachments } : {}),
|
|
1226
1271
|
})
|
|
1227
1272
|
} else {
|
|
1228
1273
|
observed.push({
|
|
@@ -1451,6 +1496,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1451
1496
|
...(live.resolvedNames.chatName !== undefined ? { chatName: live.resolvedNames.chatName } : {}),
|
|
1452
1497
|
thread: live.key.thread,
|
|
1453
1498
|
...(live.currentTurnAuthorId !== null ? { lastInboundAuthorId: live.currentTurnAuthorId } : {}),
|
|
1499
|
+
...(live.currentTurnReactionRef !== null ? { reactionRef: live.currentTurnReactionRef } : {}),
|
|
1454
1500
|
participants: live.participants,
|
|
1455
1501
|
...(membership !== null ? { membership } : {}),
|
|
1456
1502
|
}
|
|
@@ -1494,14 +1540,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1494
1540
|
const batch = live.promptQueue.splice(0, live.promptQueue.length)
|
|
1495
1541
|
const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
|
|
1496
1542
|
const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
|
|
1543
|
+
live.currentTurnAttachments = collectTurnAttachments(observed, batch)
|
|
1497
1544
|
|
|
1498
1545
|
if (batch.length > 0) {
|
|
1499
1546
|
live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
|
|
1500
1547
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1548
|
+
live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
|
|
1549
|
+
live.currentTurnEngageReactions = batch.flatMap((m) =>
|
|
1550
|
+
m.engageReaction !== undefined ? [m.engageReaction] : [],
|
|
1551
|
+
)
|
|
1501
1552
|
live.consecutiveSends.clear()
|
|
1502
1553
|
live.lastSentText.clear()
|
|
1503
1554
|
live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
|
|
1504
1555
|
} else if (live.lastTurnAuthorId !== null) {
|
|
1556
|
+
live.currentTurnEngageReactions = []
|
|
1505
1557
|
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
1506
1558
|
// restore the author identity from the prior turn so author-
|
|
1507
1559
|
// scoped role resolution still works on this turn. The drain
|
|
@@ -1516,6 +1568,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1516
1568
|
// author prior turn like alice→bob restores `bob`, not alice.
|
|
1517
1569
|
live.currentTurnAuthorId = live.lastTurnAuthorId
|
|
1518
1570
|
live.currentTurnAuthorIds = new Set(live.lastTurnAuthorIds)
|
|
1571
|
+
} else {
|
|
1572
|
+
live.currentTurnEngageReactions = []
|
|
1519
1573
|
}
|
|
1520
1574
|
|
|
1521
1575
|
// Update the live origin holder so this turn's tool.before events
|
|
@@ -1542,6 +1596,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1542
1596
|
logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
|
|
1543
1597
|
const promptStart = now()
|
|
1544
1598
|
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
1599
|
+
const engageAddPromises = live.currentTurnEngageReactions
|
|
1545
1600
|
live.turnSeq++
|
|
1546
1601
|
live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
|
|
1547
1602
|
live.policyDeniedToolSendsThisTurn.clear()
|
|
@@ -1556,6 +1611,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1556
1611
|
live.consecutiveSends.clear()
|
|
1557
1612
|
live.lastSentText.clear()
|
|
1558
1613
|
} finally {
|
|
1614
|
+
const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
|
|
1615
|
+
if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
|
|
1559
1616
|
await fireSessionTurnEnd(live)
|
|
1560
1617
|
}
|
|
1561
1618
|
await fireSessionIdle(live)
|
|
@@ -1568,6 +1625,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1568
1625
|
live.draining = false
|
|
1569
1626
|
live.currentTurnAuthorId = null
|
|
1570
1627
|
live.currentTurnAuthorIds = new Set()
|
|
1628
|
+
live.currentTurnReactionRef = null
|
|
1629
|
+
live.currentTurnEngageReactions = []
|
|
1630
|
+
live.currentTurnAttachments = []
|
|
1571
1631
|
await stopTypingHeartbeat(live)
|
|
1572
1632
|
}
|
|
1573
1633
|
}
|
|
@@ -1816,9 +1876,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1816
1876
|
|
|
1817
1877
|
publishInbound(event, 'engage', live.sessionId)
|
|
1818
1878
|
|
|
1879
|
+
const engageReaction = autoReactOnEngage(event)
|
|
1880
|
+
|
|
1819
1881
|
updateLoopGuard(live, event)
|
|
1820
1882
|
|
|
1821
|
-
enqueue(live, event)
|
|
1883
|
+
enqueue(live, event, engageReaction)
|
|
1822
1884
|
|
|
1823
1885
|
// Start showing "typing..." the moment we know we're going to engage,
|
|
1824
1886
|
// so users see the indicator during the debounce window — not just
|
|
@@ -1900,7 +1962,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1900
1962
|
}
|
|
1901
1963
|
}
|
|
1902
1964
|
|
|
1903
|
-
const enqueue = (
|
|
1965
|
+
const enqueue = (
|
|
1966
|
+
live: LiveSession,
|
|
1967
|
+
event: InboundMessage,
|
|
1968
|
+
engageReaction: Promise<ReactionRef | null> | null,
|
|
1969
|
+
): void => {
|
|
1904
1970
|
live.promptQueue.push({
|
|
1905
1971
|
text: event.text,
|
|
1906
1972
|
...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
|
|
@@ -1908,6 +1974,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1908
1974
|
authorName: event.authorName,
|
|
1909
1975
|
authorIsBot: event.authorIsBot,
|
|
1910
1976
|
externalMessageId: event.externalMessageId,
|
|
1977
|
+
...(event.reactionRef !== undefined ? { reactionRef: event.reactionRef } : {}),
|
|
1978
|
+
...(engageReaction !== null ? { engageReaction } : {}),
|
|
1911
1979
|
isBotMention: event.isBotMention,
|
|
1912
1980
|
replyToBotMessageId: event.replyToBotMessageId,
|
|
1913
1981
|
isDm: event.isDm,
|
|
@@ -1925,6 +1993,134 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1925
1993
|
set.add(cb)
|
|
1926
1994
|
}
|
|
1927
1995
|
|
|
1996
|
+
const registerReaction = (adapter: ChannelKey['adapter'], cb: ReactionCallback): void => {
|
|
1997
|
+
let set = reactionCallbacks.get(adapter)
|
|
1998
|
+
if (!set) {
|
|
1999
|
+
set = new Set()
|
|
2000
|
+
reactionCallbacks.set(adapter, set)
|
|
2001
|
+
}
|
|
2002
|
+
set.add(cb)
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
const unregisterReaction = (adapter: ChannelKey['adapter'], cb: ReactionCallback): void => {
|
|
2006
|
+
reactionCallbacks.get(adapter)?.delete(cb)
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
const registerRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
|
|
2010
|
+
let set = removeReactionCallbacks.get(adapter)
|
|
2011
|
+
if (!set) {
|
|
2012
|
+
set = new Set()
|
|
2013
|
+
removeReactionCallbacks.set(adapter, set)
|
|
2014
|
+
}
|
|
2015
|
+
set.add(cb)
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
const unregisterRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
|
|
2019
|
+
removeReactionCallbacks.get(adapter)?.delete(cb)
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const react = async (req: ReactionRequest): Promise<ReactionResult> => {
|
|
2023
|
+
if (req.reactionRef.adapter !== req.adapter) {
|
|
2024
|
+
return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
|
|
2025
|
+
}
|
|
2026
|
+
const callbacks = reactionCallbacks.get(req.adapter)
|
|
2027
|
+
if (!callbacks || callbacks.size === 0) {
|
|
2028
|
+
return { ok: false, error: `adapter "${req.adapter}" does not support reactions`, code: 'unsupported' }
|
|
2029
|
+
}
|
|
2030
|
+
let lastError: ReactionResult | undefined
|
|
2031
|
+
for (const cb of Array.from(callbacks)) {
|
|
2032
|
+
// A ReactionCallback that throws must not reject this promise: react() is
|
|
2033
|
+
// called both fire-and-forget (autoReactOnEngage) and awaited by the
|
|
2034
|
+
// channel_react tool, and neither should have to wrap it in try/catch. A
|
|
2035
|
+
// throw is converted to a transient failure result so every caller gets a
|
|
2036
|
+
// uniform { ok: false } instead of an exception.
|
|
2037
|
+
const result = await cb(req).catch(
|
|
2038
|
+
(err): ReactionResult => ({ ok: false, error: describe(err), code: 'transient' }),
|
|
2039
|
+
)
|
|
2040
|
+
if (result.ok) return result
|
|
2041
|
+
lastError = result
|
|
2042
|
+
}
|
|
2043
|
+
return lastError ?? { ok: false, error: 'no reaction callback handled request', code: 'unsupported' }
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
const removeReaction = async (req: RemoveReactionRequest): Promise<ReactionResult> => {
|
|
2047
|
+
if (req.reactionRef.adapter !== req.adapter) {
|
|
2048
|
+
return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
|
|
2049
|
+
}
|
|
2050
|
+
const callbacks = removeReactionCallbacks.get(req.adapter)
|
|
2051
|
+
if (!callbacks || callbacks.size === 0) {
|
|
2052
|
+
return { ok: false, error: `adapter "${req.adapter}" does not support reaction removal`, code: 'unsupported' }
|
|
2053
|
+
}
|
|
2054
|
+
let lastError: ReactionResult | undefined
|
|
2055
|
+
for (const cb of Array.from(callbacks)) {
|
|
2056
|
+
const result = await cb(req).catch(
|
|
2057
|
+
(err): ReactionResult => ({ ok: false, error: describe(err), code: 'transient' }),
|
|
2058
|
+
)
|
|
2059
|
+
if (result.ok) return result
|
|
2060
|
+
lastError = result
|
|
2061
|
+
}
|
|
2062
|
+
return lastError ?? { ok: false, error: 'no reaction removal callback handled request', code: 'unsupported' }
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// Best-effort acknowledgment: drop an :eyes: on the triggering inbound the
|
|
2066
|
+
// moment we decide to engage, replacing the old "On it" ack comment on
|
|
2067
|
+
// GitHub. Fire-and-forget so a reaction failure (missing permission, the
|
|
2068
|
+
// adapter not supporting reactions, a transient API error) can NEVER block
|
|
2069
|
+
// engagement, enqueueing, or the agent's actual reply. No reactionRef =
|
|
2070
|
+
// nothing reactable (synthetic inbounds, reaction-less adapters) = silent skip.
|
|
2071
|
+
const autoReactOnEngage = (event: InboundMessage): Promise<ReactionRef | null> | null => {
|
|
2072
|
+
if (event.reactionRef === undefined) return null
|
|
2073
|
+
const addResult = react({
|
|
2074
|
+
adapter: event.adapter,
|
|
2075
|
+
workspace: event.workspace,
|
|
2076
|
+
chat: event.chat,
|
|
2077
|
+
thread: event.thread,
|
|
2078
|
+
reactionRef: event.reactionRef,
|
|
2079
|
+
emoji: ENGAGE_REACTION_EMOJI,
|
|
2080
|
+
})
|
|
2081
|
+
const addReactionRef = addResult.then((r) => (r.ok ? (r.reactionRef ?? null) : null)).catch(() => null)
|
|
2082
|
+
void addResult
|
|
2083
|
+
.then((result) => {
|
|
2084
|
+
if (!result.ok && result.code !== 'unsupported') {
|
|
2085
|
+
logger.info(`[channels] engage-react failed adapter=${event.adapter} chat=${event.chat}: ${result.error}`)
|
|
2086
|
+
}
|
|
2087
|
+
})
|
|
2088
|
+
.catch((err) => {
|
|
2089
|
+
logger.info(`[channels] engage-react threw adapter=${event.adapter} chat=${event.chat}: ${describe(err)}`)
|
|
2090
|
+
})
|
|
2091
|
+
return addReactionRef
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
const dropEngageReactionsAfterReply = (live: LiveSession, addPromises: Array<Promise<ReactionRef | null>>): void => {
|
|
2095
|
+
for (const addPromise of addPromises) dropOneEngageReactionAfterReply(live, addPromise)
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
const dropOneEngageReactionAfterReply = (live: LiveSession, addPromise: Promise<ReactionRef | null>): void => {
|
|
2099
|
+
void addPromise
|
|
2100
|
+
.then((reactionRef) => {
|
|
2101
|
+
if (reactionRef === null) return undefined
|
|
2102
|
+
return removeReaction({
|
|
2103
|
+
adapter: live.key.adapter,
|
|
2104
|
+
workspace: live.key.workspace,
|
|
2105
|
+
chat: live.key.chat,
|
|
2106
|
+
thread: live.key.thread,
|
|
2107
|
+
reactionRef,
|
|
2108
|
+
})
|
|
2109
|
+
})
|
|
2110
|
+
.then((result) => {
|
|
2111
|
+
if (result && !result.ok && result.code !== 'unsupported' && result.code !== 'not-found') {
|
|
2112
|
+
logger.info(
|
|
2113
|
+
`[channels] engage-unreact failed adapter=${live.key.adapter} chat=${live.key.chat}: ${result.error}`,
|
|
2114
|
+
)
|
|
2115
|
+
}
|
|
2116
|
+
})
|
|
2117
|
+
.catch((err) => {
|
|
2118
|
+
logger.info(
|
|
2119
|
+
`[channels] engage-unreact threw adapter=${live.key.adapter} chat=${live.key.chat}: ${describe(err)}`,
|
|
2120
|
+
)
|
|
2121
|
+
})
|
|
2122
|
+
}
|
|
2123
|
+
|
|
1928
2124
|
const unregisterOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
|
|
1929
2125
|
outboundCallbacks.get(adapter)?.delete(cb)
|
|
1930
2126
|
}
|
|
@@ -2058,9 +2254,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2058
2254
|
// Walk newest → oldest so that when an id collides across messages
|
|
2059
2255
|
// (e.g. two photos in the same session each labelled `#1`) the agent's
|
|
2060
2256
|
// `attachment_id: 1` always resolves to the CURRENT inbound's
|
|
2061
|
-
// attachment.
|
|
2062
|
-
//
|
|
2063
|
-
//
|
|
2257
|
+
// attachment. currentTurnAttachments holds the in-flight turn — the
|
|
2258
|
+
// only place the about-to-be-viewed attachment lives once drain() has
|
|
2259
|
+
// spliced promptQueue empty — and is therefore the freshest; promptQueue
|
|
2260
|
+
// then holds any inbound that arrived mid-turn. Within each list,
|
|
2261
|
+
// append-order maps to wall-clock order, so iterating in reverse gives
|
|
2262
|
+
// recency.
|
|
2263
|
+
const found = findAttachmentById(live.currentTurnAttachments, args.id)
|
|
2264
|
+
if (found !== null) return found
|
|
2064
2265
|
const haystacks: ReadonlyArray<ReadonlyArray<{ attachments?: readonly InboundAttachment[] }>> = [
|
|
2065
2266
|
live.promptQueue,
|
|
2066
2267
|
live.contextBuffer,
|
|
@@ -2068,8 +2269,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2068
2269
|
for (const haystack of haystacks) {
|
|
2069
2270
|
for (let i = haystack.length - 1; i >= 0; i--) {
|
|
2070
2271
|
const item = haystack[i]
|
|
2071
|
-
const
|
|
2072
|
-
if (
|
|
2272
|
+
const hit = item?.attachments?.find((attachment) => attachment.id === args.id)
|
|
2273
|
+
if (hit !== undefined) return hit
|
|
2073
2274
|
}
|
|
2074
2275
|
}
|
|
2075
2276
|
return null
|
|
@@ -2079,6 +2280,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2079
2280
|
const live = liveSessions.get(channelKeyId(args))
|
|
2080
2281
|
if (live === undefined) return []
|
|
2081
2282
|
const ids = new Set<number>()
|
|
2283
|
+
for (const attachment of live.currentTurnAttachments) ids.add(attachment.id)
|
|
2082
2284
|
for (const item of [...live.promptQueue, ...live.contextBuffer]) {
|
|
2083
2285
|
for (const attachment of item.attachments ?? []) ids.add(attachment.id)
|
|
2084
2286
|
}
|
|
@@ -2617,6 +2819,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2617
2819
|
getSendRate,
|
|
2618
2820
|
registerOutbound,
|
|
2619
2821
|
unregisterOutbound,
|
|
2822
|
+
registerReaction,
|
|
2823
|
+
unregisterReaction,
|
|
2824
|
+
react,
|
|
2825
|
+
registerRemoveReaction,
|
|
2826
|
+
unregisterRemoveReaction,
|
|
2827
|
+
removeReaction,
|
|
2620
2828
|
registerTyping,
|
|
2621
2829
|
unregisterTyping,
|
|
2622
2830
|
registerChannelNameResolver,
|
|
@@ -2701,6 +2909,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2701
2909
|
}
|
|
2702
2910
|
}
|
|
2703
2911
|
|
|
2912
|
+
function collectTurnAttachments(
|
|
2913
|
+
observed: readonly ObservedInbound[],
|
|
2914
|
+
batch: readonly QueuedInbound[],
|
|
2915
|
+
): readonly InboundAttachment[] {
|
|
2916
|
+
const out: InboundAttachment[] = []
|
|
2917
|
+
for (const item of observed) out.push(...(item.attachments ?? []))
|
|
2918
|
+
for (const item of batch) out.push(...(item.attachments ?? []))
|
|
2919
|
+
return out
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
function findAttachmentById(attachments: readonly InboundAttachment[], id: number): InboundAttachment | null {
|
|
2923
|
+
for (let i = attachments.length - 1; i >= 0; i--) {
|
|
2924
|
+
const attachment = attachments[i]
|
|
2925
|
+
if (attachment?.id === id) return attachment
|
|
2926
|
+
}
|
|
2927
|
+
return null
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2704
2930
|
function composeTurnPrompt(
|
|
2705
2931
|
observed: readonly ObservedInbound[],
|
|
2706
2932
|
batch: readonly QueuedInbound[],
|
|
@@ -2782,13 +3008,14 @@ function composeTurnPrompt(
|
|
|
2782
3008
|
)
|
|
2783
3009
|
}
|
|
2784
3010
|
// Group-chat nudge: same SYSTEM MESSAGE convention as the loop guard. We
|
|
2785
|
-
// engaged this turn
|
|
2786
|
-
//
|
|
2787
|
-
//
|
|
2788
|
-
//
|
|
2789
|
-
//
|
|
2790
|
-
//
|
|
2791
|
-
//
|
|
3011
|
+
// engaged this turn — possibly via sticky credit, which now wakes us on
|
|
3012
|
+
// every follow-up in a group too (the engagement gate is content-blind by
|
|
3013
|
+
// design). In a multi-human room the default "answer everything" posture is
|
|
3014
|
+
// wrong, so this nudge is the ONLY thing that makes the bot selective: it
|
|
3015
|
+
// tells the model to answer genuine follow-ups and stay silent on chatter.
|
|
3016
|
+
// The gate gets us into the turn; the model decides whether to speak.
|
|
3017
|
+
// Cache-neutral (user-turn suffix), and skipped when the loop guard already
|
|
3018
|
+
// fired to avoid stacking two silence notices in one turn.
|
|
2792
3019
|
if (state.groupChatNudge === true && !state.loopGuardActive) {
|
|
2793
3020
|
parts.push(
|
|
2794
3021
|
'---',
|
|
@@ -2798,10 +3025,20 @@ function composeTurnPrompt(
|
|
|
2798
3025
|
'signal from the channel router, not a message from anyone in the chat.',
|
|
2799
3026
|
'**Do not acknowledge or reply to this notice.**',
|
|
2800
3027
|
'',
|
|
2801
|
-
'
|
|
2802
|
-
'
|
|
2803
|
-
'
|
|
2804
|
-
'
|
|
3028
|
+
'You are woken on every message from someone you recently talked with, so',
|
|
3029
|
+
'most turns you should stay quiet. In a group the target shifts every',
|
|
3030
|
+
'message: before replying, identify who THIS latest message is aimed at.',
|
|
3031
|
+
'Reply ONLY when:',
|
|
3032
|
+
'- the current message is addressed to you (by name, @-mention, or reply), or',
|
|
3033
|
+
'- it directly continues your own last exchange and clearly wants an answer',
|
|
3034
|
+
' (e.g. a follow-up question about what you just said).',
|
|
3035
|
+
'',
|
|
3036
|
+
'If it is aimed at someone else — another person by name or @-mention, a',
|
|
3037
|
+
'reply to their message, or another bot — it is not your turn, even if you',
|
|
3038
|
+
'were just talking with its author. Otherwise too — chatter, side-',
|
|
3039
|
+
'conversation, banter, or anything not actually waiting on you — reply with',
|
|
3040
|
+
'`NO_REPLY` (or call `skip_response`) to stay silent and keep watching.',
|
|
3041
|
+
'When unsure, prefer silence.',
|
|
2805
3042
|
'',
|
|
2806
3043
|
'---',
|
|
2807
3044
|
'',
|
package/src/channels/types.ts
CHANGED
|
@@ -94,8 +94,65 @@ export type InboundMessage = {
|
|
|
94
94
|
// means "unknown" — the formatter renders such lines without a
|
|
95
95
|
// timestamp prefix instead of stamping them with the wrong clock.
|
|
96
96
|
ts: number
|
|
97
|
+
// Opaque, adapter-owned handle for the entity an emoji reaction would
|
|
98
|
+
// attach to. The classifier stamps it because only there is the platform-
|
|
99
|
+
// side target type still known (GitHub: issue body vs issue-comment vs
|
|
100
|
+
// pr-review-comment — all collapse to the same `chat`/`externalMessageId`
|
|
101
|
+
// pair downstream). Mirrors the `InboundAttachment.ref` opaque-handle
|
|
102
|
+
// pattern: ONLY the originating adapter's ReactionCallback knows how to
|
|
103
|
+
// parse `value`; the router and tools treat it as a pass-through token and
|
|
104
|
+
// never inspect it. Omitted when the inbound has no reactable target (e.g.
|
|
105
|
+
// synthetic review-request inbounds, or adapters without reaction support).
|
|
106
|
+
reactionRef?: ReactionRef
|
|
97
107
|
}
|
|
98
108
|
|
|
109
|
+
// Opaque reaction target handle. `adapter` lets the router refuse a ref to
|
|
110
|
+
// the wrong adapter's callback; `value` is an adapter-private encoding (for
|
|
111
|
+
// GitHub, a JSON blob distinguishing issue / issue-comment / pr-review-comment
|
|
112
|
+
// / discussion plus the numeric id). Never rendered into prompt context.
|
|
113
|
+
export type ReactionRef = {
|
|
114
|
+
adapter: AdapterId
|
|
115
|
+
value: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A request to add an emoji reaction to a previously-seen inbound. Distinct
|
|
119
|
+
// from OutboundMessage on purpose: reactions are best-effort side effects, not
|
|
120
|
+
// messages, so they bypass `send()`'s flood guard, per-turn send cap, exact-
|
|
121
|
+
// duplicate guard, sticky-credit grants, and typing heartbeat — all of which
|
|
122
|
+
// are message semantics that would misbehave on a reaction.
|
|
123
|
+
export type ReactionRequest = {
|
|
124
|
+
adapter: AdapterId
|
|
125
|
+
workspace: string
|
|
126
|
+
chat: string
|
|
127
|
+
thread?: string | null
|
|
128
|
+
reactionRef: ReactionRef
|
|
129
|
+
// Bare emoji name, no surrounding colons (e.g. 'eyes', '+1'). Each adapter
|
|
130
|
+
// maps this to its platform's reaction vocabulary and rejects unsupported
|
|
131
|
+
// names via `code: 'unsupported'`.
|
|
132
|
+
emoji: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type RemoveReactionRequest = {
|
|
136
|
+
adapter: AdapterId
|
|
137
|
+
workspace: string
|
|
138
|
+
chat: string
|
|
139
|
+
thread?: string | null
|
|
140
|
+
// Per-reaction-instance ref returned by ReactionResult.reactionRef from the
|
|
141
|
+
// add request, not the inbound message target ref used by ReactionRequest.
|
|
142
|
+
reactionRef: ReactionRef
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type ReactionErrorCode = 'permission-denied' | 'not-found' | 'unsupported' | 'rate-limited' | 'transient'
|
|
146
|
+
|
|
147
|
+
export type ReactionResult =
|
|
148
|
+
// Optional success ref identifies THIS created reaction instance for later
|
|
149
|
+
// removal, not the original message target. Adapters that cannot remove omit it.
|
|
150
|
+
{ ok: true; reactionRef?: ReactionRef } | { ok: false; error: string; code?: ReactionErrorCode }
|
|
151
|
+
|
|
152
|
+
export type ReactionCallback = (req: ReactionRequest) => Promise<ReactionResult>
|
|
153
|
+
|
|
154
|
+
export type RemoveReactionCallback = (req: RemoveReactionRequest) => Promise<ReactionResult>
|
|
155
|
+
|
|
99
156
|
// File on disk that the agent wants to attach to an outbound message. The
|
|
100
157
|
// agent runs inside a container with /agent bind-mounted from the host;
|
|
101
158
|
// `path` should be an absolute path the container can `readFile`. The
|