typeclaw 0.10.0 → 0.11.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 +37 -4
- 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 +1 -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/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/router.ts +213 -32
- package/src/channels/schema.ts +8 -7
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +135 -38
- package/src/cli/init.ts +133 -86
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +24 -32
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- 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 +12 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +45 -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,
|
|
@@ -234,6 +230,19 @@ type ObservedInbound = {
|
|
|
234
230
|
authorIsBot: boolean
|
|
235
231
|
receivedAt: number
|
|
236
232
|
ts: number
|
|
233
|
+
// Distinguishes scrollback that was bulk-loaded at session cold-start
|
|
234
|
+
// (`prefetch`) from messages that actually arrived in the channel after
|
|
235
|
+
// the session went live (`observed`). Both share the same in-memory
|
|
236
|
+
// shape because the model sees them identically in the prompt's
|
|
237
|
+
// "Recent context" block, but the quote-anchor decision must treat them
|
|
238
|
+
// differently: prefetched scrollback is HISTORICAL context, not new
|
|
239
|
+
// chatter that happened between the primary inbound and the agent's
|
|
240
|
+
// reply. Counting prefetch entries as "intervening" would fire the
|
|
241
|
+
// anchor on every fresh-thread first turn (the prefetch stamps
|
|
242
|
+
// `receivedAt = now()` AFTER the inbound was received during ensureLive,
|
|
243
|
+
// so by primary-vs-observed timestamp comparison they always look
|
|
244
|
+
// "later"). See captureQuoteCandidate.
|
|
245
|
+
source: 'prefetch' | 'observed'
|
|
237
246
|
}
|
|
238
247
|
|
|
239
248
|
type LiveSession = {
|
|
@@ -306,6 +315,31 @@ type LiveSession = {
|
|
|
306
315
|
// future hard cap without picking a threshold out of thin air.
|
|
307
316
|
sendTimestamps: Map<string, number[]>
|
|
308
317
|
successfulChannelSends: number
|
|
318
|
+
// Monotonic per-LiveSession turn counter incremented just before each
|
|
319
|
+
// `live.session.prompt(...)` call in `drain()`. Used as a turn identity
|
|
320
|
+
// so `skip_response` can record "I skipped turn N" without leaking
|
|
321
|
+
// across turns. `validateChannelTurn` only honors `skippedTurn` when it
|
|
322
|
+
// equals `turnSeq`; a stale value from a crashed/aborted prior turn is
|
|
323
|
+
// ignored (defensive: an unmatched skippedTurn would otherwise silently
|
|
324
|
+
// drop the next user-facing reply). NOT cleared on drain finally — the
|
|
325
|
+
// counter is purely monotonic; the matching comparison is what protects
|
|
326
|
+
// against stale state.
|
|
327
|
+
turnSeq: number
|
|
328
|
+
// Snapshot of `successfulChannelSends` taken at turn start (same
|
|
329
|
+
// moment `turnSeq` increments). Lets `markTurnSkipped` detect "a
|
|
330
|
+
// channel send already landed in this turn" and reject the skip,
|
|
331
|
+
// making the rejection symmetric with the send-after-skip lock in
|
|
332
|
+
// `send()`: commit to silence or commit to replying, not both,
|
|
333
|
+
// regardless of which order the model tried them in. Updated only at
|
|
334
|
+
// turn start; reads against the live counter elsewhere are intentional.
|
|
335
|
+
successfulSendsAtTurnStart: number
|
|
336
|
+
// Stamped by `markTurnSkipped` (called from the `skip_response` tool)
|
|
337
|
+
// with the current `turnSeq`. Read at the top of `validateChannelTurn`:
|
|
338
|
+
// if it matches the just-completed turn, recovery is skipped entirely
|
|
339
|
+
// (no NO_REPLY check, no Kimi leak check, no assistant-text recovery).
|
|
340
|
+
// The model has explicitly opted out of this turn and we honor that
|
|
341
|
+
// unconditionally. `null` when no skip has been recorded.
|
|
342
|
+
skippedTurn: { turnSeq: number; reason: string } | null
|
|
309
343
|
// Captured by drain() at batch dequeue; read+cleared by send() on the
|
|
310
344
|
// first tool-source send of the turn. The anchor decision (delay
|
|
311
345
|
// threshold + intervening-observed check) is evaluated at SEND time
|
|
@@ -369,6 +403,11 @@ export const TURN_CAP_ERROR =
|
|
|
369
403
|
`Send-cap reached for this turn (${MAX_CHANNEL_SENDS_PER_TURN} messages already sent to this conversation). ` +
|
|
370
404
|
'End your turn now. The user can prompt you again for more output.'
|
|
371
405
|
|
|
406
|
+
export const SKIP_RESPONSE_LOCK_ERROR =
|
|
407
|
+
'You called `skip_response` earlier in this turn, which committed to staying silent. ' +
|
|
408
|
+
'Channel sends are blocked for the rest of this turn. End your turn now; if you have ' +
|
|
409
|
+
'something to say, send it on the next turn.'
|
|
410
|
+
|
|
372
411
|
export type ChannelRouter = {
|
|
373
412
|
route: (event: InboundMessage) => Promise<void>
|
|
374
413
|
send: (msg: OutboundMessage, opts?: SendOptions) => Promise<SendResult>
|
|
@@ -434,6 +473,31 @@ export type ChannelRouter = {
|
|
|
434
473
|
durationMs: number
|
|
435
474
|
error?: string
|
|
436
475
|
}) => { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' }
|
|
476
|
+
// Record that the agent invoked `skip_response` during the current turn
|
|
477
|
+
// for the channel session identified by `parentSessionId`. The reason is
|
|
478
|
+
// logged at INFO level inside `validateChannelTurn` (single log line per
|
|
479
|
+
// skip, so operators see exactly one record per silent turn). Stamps the
|
|
480
|
+
// current `turnSeq` on the live session so a stale record from an earlier
|
|
481
|
+
// turn cannot drop a future legitimate reply.
|
|
482
|
+
//
|
|
483
|
+
// Returns:
|
|
484
|
+
// - 'recorded' — the live session was found and the skip was stamped
|
|
485
|
+
// - 'send-already-happened' — a tool-source channel send already landed
|
|
486
|
+
// in this turn; the skip is refused (symmetric with
|
|
487
|
+
// the send-after-skip lock in `send()`) so the model
|
|
488
|
+
// cannot land a reply AND claim silence. The flag is
|
|
489
|
+
// NOT stamped, so the turn proceeds as a normal
|
|
490
|
+
// reply turn.
|
|
491
|
+
// - 'no-live-session' — no matching channel session (e.g. tool fired
|
|
492
|
+
// outside a channel origin); the tool should
|
|
493
|
+
// still log the reason but cannot suppress.
|
|
494
|
+
markTurnSkipped: (args: {
|
|
495
|
+
parentSessionId: string
|
|
496
|
+
reason: string
|
|
497
|
+
}) =>
|
|
498
|
+
| { kind: 'recorded'; keyId: string }
|
|
499
|
+
| { kind: 'send-already-happened'; keyId: string }
|
|
500
|
+
| { kind: 'no-live-session' }
|
|
437
501
|
stop: () => Promise<void>
|
|
438
502
|
liveCount: () => number
|
|
439
503
|
__testing?: {
|
|
@@ -926,6 +990,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
926
990
|
lastSentText: new Map(),
|
|
927
991
|
sendTimestamps: new Map(),
|
|
928
992
|
successfulChannelSends: 0,
|
|
993
|
+
turnSeq: 0,
|
|
994
|
+
successfulSendsAtTurnStart: 0,
|
|
995
|
+
skippedTurn: null,
|
|
929
996
|
pendingQuoteCandidate: null,
|
|
930
997
|
recentEngagedPeerBotTurns: [],
|
|
931
998
|
consecutiveEngagedPeerBotTurns: 0,
|
|
@@ -1019,6 +1086,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1019
1086
|
authorIsBot: item.message.isBot,
|
|
1020
1087
|
receivedAt: now(),
|
|
1021
1088
|
ts: item.message.ts,
|
|
1089
|
+
source: 'prefetch',
|
|
1022
1090
|
})
|
|
1023
1091
|
} else {
|
|
1024
1092
|
observed.push({
|
|
@@ -1028,6 +1096,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1028
1096
|
authorIsBot: true,
|
|
1029
1097
|
receivedAt: now(),
|
|
1030
1098
|
ts: 0,
|
|
1099
|
+
source: 'prefetch',
|
|
1031
1100
|
})
|
|
1032
1101
|
}
|
|
1033
1102
|
}
|
|
@@ -1093,7 +1162,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1093
1162
|
return
|
|
1094
1163
|
}
|
|
1095
1164
|
if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
|
|
1096
|
-
logger.warn(
|
|
1165
|
+
logger.warn(
|
|
1166
|
+
`[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms; prompt still in flight`,
|
|
1167
|
+
)
|
|
1097
1168
|
live.typingTimedOut = true
|
|
1098
1169
|
void stopTypingHeartbeat(live)
|
|
1099
1170
|
return
|
|
@@ -1218,6 +1289,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1218
1289
|
const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
|
|
1219
1290
|
const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
|
|
1220
1291
|
const text = composeTurnPrompt(observed, batch, {
|
|
1292
|
+
adapter: live.key.adapter,
|
|
1221
1293
|
loopGuardActive: live.loopGuardActive,
|
|
1222
1294
|
systemReminders: reminders,
|
|
1223
1295
|
})
|
|
@@ -1227,7 +1299,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1227
1299
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1228
1300
|
live.consecutiveSends.clear()
|
|
1229
1301
|
live.lastSentText.clear()
|
|
1230
|
-
live.pendingQuoteCandidate = captureQuoteCandidate(batch, observed)
|
|
1302
|
+
live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
|
|
1231
1303
|
} else if (live.lastTurnAuthorId !== null) {
|
|
1232
1304
|
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
1233
1305
|
// restore the author identity from the prior turn so author-
|
|
@@ -1259,6 +1331,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1259
1331
|
logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
|
|
1260
1332
|
const promptStart = now()
|
|
1261
1333
|
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
1334
|
+
live.turnSeq++
|
|
1335
|
+
live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
|
|
1262
1336
|
await fireSessionTurnStart(live, text)
|
|
1263
1337
|
try {
|
|
1264
1338
|
await live.session.prompt(text)
|
|
@@ -1538,6 +1612,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1538
1612
|
authorIsBot: event.authorIsBot,
|
|
1539
1613
|
receivedAt: now(),
|
|
1540
1614
|
ts: event.ts,
|
|
1615
|
+
source: 'observed',
|
|
1541
1616
|
})
|
|
1542
1617
|
if (live.contextBuffer.length > CONTEXT_BUFFER_SIZE) {
|
|
1543
1618
|
live.contextBuffer.splice(0, live.contextBuffer.length - CONTEXT_BUFFER_SIZE)
|
|
@@ -1711,17 +1786,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1711
1786
|
const live = liveSessions.get(keyId)
|
|
1712
1787
|
const sendKey = consecutiveSendKey(msg.chat, msg.thread)
|
|
1713
1788
|
// Tool-source sends consume the captured quote candidate exactly
|
|
1714
|
-
// once per turn — the
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1789
|
+
// once per turn — the intervening-observed check runs HERE against
|
|
1790
|
+
// the live buffer so the relevant signal is actual channel chatter
|
|
1791
|
+
// between inbound and reply landing, not drain-vs-send timing
|
|
1792
|
+
// artifacts. System sources (recovery, role-
|
|
1718
1793
|
// claim) skip so they can't accidentally swallow the candidate
|
|
1719
1794
|
// 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.
|
|
1795
|
+
// returns null (nothing intervened), the candidate is cleared — a
|
|
1796
|
+
// multi-part reply must not retroactively anchor chunk 2.
|
|
1723
1797
|
if (live && source === 'tool' && live.pendingQuoteCandidate !== null) {
|
|
1724
|
-
const
|
|
1798
|
+
const quoteCandidate = refreshQuoteCandidate(live.pendingQuoteCandidate, live.contextBuffer)
|
|
1799
|
+
const anchor = decideQuoteAnchor(quoteCandidate, now(), options.configForAdapter(msg.adapter))
|
|
1725
1800
|
if (anchor !== null) msg = { ...msg, text: prependQuoteAnchor(msg.text ?? '', anchor) }
|
|
1726
1801
|
live.pendingQuoteCandidate = null
|
|
1727
1802
|
}
|
|
@@ -1739,6 +1814,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1739
1814
|
let priorLastSentText: string | undefined
|
|
1740
1815
|
let reserved = false
|
|
1741
1816
|
if (live && source === 'tool') {
|
|
1817
|
+
// Tool-source send after `skip_response` for the same turn is a contract
|
|
1818
|
+
// violation: the model already committed to silence. Reject before any
|
|
1819
|
+
// state mutation so the model gets a clear error and the channel stays
|
|
1820
|
+
// silent. System-source sends (recovery, role-claim) are not affected.
|
|
1821
|
+
if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
|
|
1822
|
+
return { ok: false, error: SKIP_RESPONSE_LOCK_ERROR, code: 'skip-locked' }
|
|
1823
|
+
}
|
|
1742
1824
|
const currentCount = live.consecutiveSends.get(sendKey) ?? 0
|
|
1743
1825
|
if (currentCount >= MAX_CHANNEL_SENDS_PER_TURN) {
|
|
1744
1826
|
return { ok: false, error: TURN_CAP_ERROR, code: 'turn-cap' }
|
|
@@ -1825,6 +1907,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1825
1907
|
}
|
|
1826
1908
|
|
|
1827
1909
|
const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
|
|
1910
|
+
// `skip_response` short-circuit. Must run before the `successfulChannelSends`
|
|
1911
|
+
// check so a model that called both `skip_response` and a channel tool in
|
|
1912
|
+
// the same turn still resolves as "skipped" — the rejection inside `send()`
|
|
1913
|
+
// means the channel tool returned an error and never actually delivered.
|
|
1914
|
+
// Stale-flag protection: only honor when stamped on the just-completed
|
|
1915
|
+
// turn. A flag set by a previous turn that crashed before validation
|
|
1916
|
+
// would otherwise drop the next legitimate user-facing reply.
|
|
1917
|
+
if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
|
|
1918
|
+
const { reason } = live.skippedTurn
|
|
1919
|
+
live.skippedTurn = null
|
|
1920
|
+
logger.info(`[channels] ${live.keyId} skipped_by_tool reason=${JSON.stringify(reason)}`)
|
|
1921
|
+
return
|
|
1922
|
+
}
|
|
1828
1923
|
if (live.successfulChannelSends > successfulSendsBeforePrompt) return
|
|
1829
1924
|
|
|
1830
1925
|
const candidate = recoverableAssistantText(live.session)
|
|
@@ -2071,6 +2166,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2071
2166
|
return { kind: 'no-live-session' }
|
|
2072
2167
|
}
|
|
2073
2168
|
|
|
2169
|
+
const markTurnSkipped = (args: {
|
|
2170
|
+
parentSessionId: string
|
|
2171
|
+
reason: string
|
|
2172
|
+
}):
|
|
2173
|
+
| { kind: 'recorded'; keyId: string }
|
|
2174
|
+
| { kind: 'send-already-happened'; keyId: string }
|
|
2175
|
+
| { kind: 'no-live-session' } => {
|
|
2176
|
+
for (const live of liveSessions.values()) {
|
|
2177
|
+
if (live.destroyed) continue
|
|
2178
|
+
if (live.sessionId !== args.parentSessionId) continue
|
|
2179
|
+
if (live.successfulChannelSends > live.successfulSendsAtTurnStart) {
|
|
2180
|
+
return { kind: 'send-already-happened', keyId: live.keyId }
|
|
2181
|
+
}
|
|
2182
|
+
live.skippedTurn = { turnSeq: live.turnSeq, reason: args.reason }
|
|
2183
|
+
return { kind: 'recorded', keyId: live.keyId }
|
|
2184
|
+
}
|
|
2185
|
+
return { kind: 'no-live-session' }
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2074
2188
|
return {
|
|
2075
2189
|
route,
|
|
2076
2190
|
send,
|
|
@@ -2093,6 +2207,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2093
2207
|
executeCommand,
|
|
2094
2208
|
getSelfAliases: computeSelfAliases,
|
|
2095
2209
|
injectSubagentCompletionReminder,
|
|
2210
|
+
markTurnSkipped,
|
|
2096
2211
|
stop,
|
|
2097
2212
|
liveCount: () => liveSessions.size,
|
|
2098
2213
|
__testing: {
|
|
@@ -2119,7 +2234,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2119
2234
|
return
|
|
2120
2235
|
}
|
|
2121
2236
|
if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
|
|
2122
|
-
logger.warn(
|
|
2237
|
+
logger.warn(
|
|
2238
|
+
`[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms; prompt still in flight`,
|
|
2239
|
+
)
|
|
2123
2240
|
live.typingTimedOut = true
|
|
2124
2241
|
await stopTypingHeartbeat(live)
|
|
2125
2242
|
return
|
|
@@ -2159,8 +2276,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2159
2276
|
function composeTurnPrompt(
|
|
2160
2277
|
observed: readonly ObservedInbound[],
|
|
2161
2278
|
batch: readonly QueuedInbound[],
|
|
2162
|
-
state: { loopGuardActive: boolean; systemReminders?: readonly string[] } = {
|
|
2279
|
+
state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[] } = {
|
|
2280
|
+
loopGuardActive: false,
|
|
2281
|
+
},
|
|
2163
2282
|
): string {
|
|
2283
|
+
const adapter = state.adapter ?? 'discord-bot'
|
|
2164
2284
|
const parts: string[] = []
|
|
2165
2285
|
// System reminders (subagent-completion wakeups today) lead the turn body
|
|
2166
2286
|
// because they are typically what triggered the drain — when the prompt
|
|
@@ -2226,7 +2346,7 @@ function composeTurnPrompt(
|
|
|
2226
2346
|
if (observed.length > 0) {
|
|
2227
2347
|
parts.push('## Recent context (not addressed to you, for awareness only)')
|
|
2228
2348
|
for (const o of observed) {
|
|
2229
|
-
parts.push(formatAuthorLine(o.ts, o.authorId, o.authorName, o.authorIsBot, o.text))
|
|
2349
|
+
parts.push(formatAuthorLine(o.ts, adapter, o.authorId, o.authorName, o.authorIsBot, o.text))
|
|
2230
2350
|
}
|
|
2231
2351
|
parts.push('')
|
|
2232
2352
|
}
|
|
@@ -2244,7 +2364,7 @@ function composeTurnPrompt(
|
|
|
2244
2364
|
)
|
|
2245
2365
|
}
|
|
2246
2366
|
for (const b of batch) {
|
|
2247
|
-
parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
|
|
2367
|
+
parts.push(formatAuthorLine(b.ts, adapter, b.authorId, b.authorName, b.authorIsBot, b.text))
|
|
2248
2368
|
}
|
|
2249
2369
|
}
|
|
2250
2370
|
return parts.join('\n')
|
|
@@ -2252,6 +2372,7 @@ function composeTurnPrompt(
|
|
|
2252
2372
|
|
|
2253
2373
|
function formatAuthorLine(
|
|
2254
2374
|
ts: number,
|
|
2375
|
+
adapter: AdapterId,
|
|
2255
2376
|
authorId: string,
|
|
2256
2377
|
authorName: string,
|
|
2257
2378
|
authorIsBot: boolean,
|
|
@@ -2259,15 +2380,44 @@ function formatAuthorLine(
|
|
|
2259
2380
|
): string {
|
|
2260
2381
|
const tag = authorIsBot ? ' [bot]' : ''
|
|
2261
2382
|
const stamp = ts > 0 ? `[${new Date(ts).toISOString()}] ` : ''
|
|
2262
|
-
return `${stamp}
|
|
2383
|
+
return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
|
|
2263
2384
|
}
|
|
2264
2385
|
|
|
2265
2386
|
export type QuoteAnchorSource = {
|
|
2387
|
+
adapter: AdapterId
|
|
2388
|
+
authorId: string
|
|
2266
2389
|
authorName: string
|
|
2267
2390
|
text: string
|
|
2268
2391
|
}
|
|
2269
2392
|
|
|
2270
|
-
//
|
|
2393
|
+
// Picks the right author syntax for the platform so prompts and rendered
|
|
2394
|
+
// quote anchors use the same form the user would type in that channel.
|
|
2395
|
+
// Slack/Discord need id mentions (`<@U…>`), GitHub needs handle mentions
|
|
2396
|
+
// (`@login`) because inbound author ids are numeric, and adapters without
|
|
2397
|
+
// stable id-only mention syntax fall back to plain display names.
|
|
2398
|
+
//
|
|
2399
|
+
// Notification semantics: Slack and Discord both render `<@…>` as a
|
|
2400
|
+
// styled mention link inside blockquotes; whether the mentioned user is
|
|
2401
|
+
// PINGED is a separate platform-level UX (Slack pings on first appearance
|
|
2402
|
+
// in the message regardless of position, Discord respects the
|
|
2403
|
+
// `allowed_mentions` field which defaults to "ping everyone parsed").
|
|
2404
|
+
// This matches PR #374's intent — the user IS being notified that the
|
|
2405
|
+
// agent replied to them, which is the whole point of a quote anchor.
|
|
2406
|
+
function formatAuthorReference(adapter: AdapterId, authorId: string, authorName: string): string {
|
|
2407
|
+
const displayName = authorName.trim() !== '' ? authorName.trim() : authorId
|
|
2408
|
+
switch (adapter) {
|
|
2409
|
+
case 'slack-bot':
|
|
2410
|
+
case 'discord-bot':
|
|
2411
|
+
return `<@${authorId}>`
|
|
2412
|
+
case 'github':
|
|
2413
|
+
return displayName.startsWith('@') ? displayName : `@${displayName}`
|
|
2414
|
+
case 'telegram-bot':
|
|
2415
|
+
case 'kakaotalk':
|
|
2416
|
+
return displayName
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
// Renders the single-line `> @mention: excerpt` blockquote prepended to
|
|
2271
2421
|
// outbound replies when the router decides the reply needs an anchor.
|
|
2272
2422
|
// Collapses newlines to spaces so a multi-line user message renders on
|
|
2273
2423
|
// one quoted line (markdown blockquote semantics: a blank line ends the
|
|
@@ -2286,17 +2436,27 @@ export function renderQuoteAnchor(source: QuoteAnchorSource): string {
|
|
|
2286
2436
|
: collapsed.length > QUOTED_REPLY_EXCERPT_MAX_CHARS
|
|
2287
2437
|
? `${collapsed.slice(0, QUOTED_REPLY_EXCERPT_MAX_CHARS - 1)}…`
|
|
2288
2438
|
: collapsed
|
|
2289
|
-
|
|
2439
|
+
const mention = formatAuthorReference(source.adapter, source.authorId, source.authorName)
|
|
2440
|
+
return `> ${mention}: ${excerpt}`
|
|
2290
2441
|
}
|
|
2291
2442
|
|
|
2443
|
+
// Separates the anchor from the reply with a blank line (`\n\n`), not a
|
|
2444
|
+
// single `\n`. In standard GFM and Slack's `markdown` block, a single
|
|
2445
|
+
// `\n` inside a paragraph is a soft break rendered as whitespace, which
|
|
2446
|
+
// keeps the `>` blockquote styling running visually through the next
|
|
2447
|
+
// line — i.e. the agent's reply text gets swallowed into the quote. The
|
|
2448
|
+
// blank line forces a paragraph boundary that unambiguously ends the
|
|
2449
|
+
// blockquote on every renderer (CommonMark, GFM, Slack mrkdwn, Discord
|
|
2450
|
+
// markdown).
|
|
2292
2451
|
export function prependQuoteAnchor(replyText: string, source: QuoteAnchorSource): string {
|
|
2293
2452
|
const anchor = renderQuoteAnchor(source)
|
|
2294
2453
|
if (replyText === '') return anchor
|
|
2295
|
-
return `${anchor}\n${replyText}`
|
|
2454
|
+
return `${anchor}\n\n${replyText}`
|
|
2296
2455
|
}
|
|
2297
2456
|
|
|
2298
2457
|
type QuoteAnchorBatchEntry = {
|
|
2299
2458
|
text: string
|
|
2459
|
+
authorId: string
|
|
2300
2460
|
authorName: string
|
|
2301
2461
|
authorIsBot: boolean
|
|
2302
2462
|
receivedAt: number
|
|
@@ -2304,6 +2464,7 @@ type QuoteAnchorBatchEntry = {
|
|
|
2304
2464
|
|
|
2305
2465
|
type QuoteAnchorObservedEntry = {
|
|
2306
2466
|
receivedAt: number
|
|
2467
|
+
source: 'prefetch' | 'observed'
|
|
2307
2468
|
}
|
|
2308
2469
|
|
|
2309
2470
|
export type QuoteAnchorCandidate = {
|
|
@@ -2316,38 +2477,58 @@ export type QuoteAnchorCandidate = {
|
|
|
2316
2477
|
// the send-side decision has the data it needs without holding a
|
|
2317
2478
|
// reference to the batch arrays. Returns null when there's nothing
|
|
2318
2479
|
// anchorable (empty batch, primary is a bot).
|
|
2480
|
+
//
|
|
2481
|
+
// `hadInterveningObserved` counts ONLY live observations (`source ===
|
|
2482
|
+
// 'observed'`), not prefetched scrollback. Prefetch stamps `receivedAt =
|
|
2483
|
+
// now()` inside ensureLive — wall-clock-later than the primary inbound
|
|
2484
|
+
// that triggered ensureLive — so without this gate, every cold-start
|
|
2485
|
+
// first turn would see "intervening observed" entries and fire the
|
|
2486
|
+
// quote anchor even when the reply lands within milliseconds. The
|
|
2487
|
+
// signal we actually want is "did real new chatter arrive between the
|
|
2488
|
+
// user's inbound and the agent's reply", which only live observations
|
|
2489
|
+
// represent.
|
|
2319
2490
|
export function captureQuoteCandidate(
|
|
2491
|
+
adapter: AdapterId,
|
|
2320
2492
|
batch: readonly QuoteAnchorBatchEntry[],
|
|
2321
2493
|
observed: readonly QuoteAnchorObservedEntry[],
|
|
2322
2494
|
): QuoteAnchorCandidate | null {
|
|
2323
2495
|
if (batch.length === 0) return null
|
|
2324
2496
|
const primary = batch[batch.length - 1]!
|
|
2325
2497
|
if (primary.authorIsBot) return null
|
|
2326
|
-
const hadInterveningObserved = observed.some((o) => o.receivedAt >= primary.receivedAt)
|
|
2327
2498
|
return {
|
|
2328
|
-
source: { authorName: primary.authorName, text: primary.text },
|
|
2499
|
+
source: { adapter, authorId: primary.authorId, authorName: primary.authorName, text: primary.text },
|
|
2329
2500
|
primaryReceivedAt: primary.receivedAt,
|
|
2330
|
-
hadInterveningObserved,
|
|
2501
|
+
hadInterveningObserved: hasInterveningObserved(primary.receivedAt, observed),
|
|
2331
2502
|
}
|
|
2332
2503
|
}
|
|
2333
2504
|
|
|
2505
|
+
function refreshQuoteCandidate(
|
|
2506
|
+
candidate: QuoteAnchorCandidate,
|
|
2507
|
+
observed: readonly QuoteAnchorObservedEntry[],
|
|
2508
|
+
): QuoteAnchorCandidate {
|
|
2509
|
+
if (candidate.hadInterveningObserved) return candidate
|
|
2510
|
+
if (!hasInterveningObserved(candidate.primaryReceivedAt, observed)) return candidate
|
|
2511
|
+
return { ...candidate, hadInterveningObserved: true }
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
function hasInterveningObserved(primaryReceivedAt: number, observed: readonly QuoteAnchorObservedEntry[]): boolean {
|
|
2515
|
+
return observed.some((o) => o.source === 'observed' && o.receivedAt >= primaryReceivedAt)
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2334
2518
|
// Send-time decision: given a captured candidate and the current clock,
|
|
2335
2519
|
// returns the source to anchor against or null. Skips when:
|
|
2336
2520
|
// - quotedReply is disabled in config
|
|
2337
|
-
// -
|
|
2338
|
-
// primary inbound and now (the "felt instantaneous" path)
|
|
2521
|
+
// - no observed messages came between primary inbound and now
|
|
2339
2522
|
// A null candidate (no batch yet, or batch was bot-only) always skips.
|
|
2340
2523
|
export function decideQuoteAnchor(
|
|
2341
2524
|
candidate: QuoteAnchorCandidate | null,
|
|
2342
|
-
|
|
2525
|
+
_nowMs: number,
|
|
2343
2526
|
adapterConfig: ChannelAdapterConfig | undefined,
|
|
2344
2527
|
): QuoteAnchorSource | null {
|
|
2345
2528
|
if (candidate === null) return null
|
|
2346
2529
|
const config = adapterConfig?.quotedReply
|
|
2347
2530
|
if (config !== undefined && config.enabled === false) return null
|
|
2348
|
-
|
|
2349
|
-
const delay = nowMs - candidate.primaryReceivedAt
|
|
2350
|
-
if (delay < threshold && !candidate.hadInterveningObserved) return null
|
|
2531
|
+
if (!candidate.hadInterveningObserved) return null
|
|
2351
2532
|
return candidate.source
|
|
2352
2533
|
}
|
|
2353
2534
|
|
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
|
|