typeclaw 0.33.0 → 0.34.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/auth.schema.json +66 -0
- package/cron.schema.json +26 -2
- package/package.json +1 -1
- package/secrets.schema.json +66 -0
- package/src/agent/index.ts +7 -3
- package/src/agent/session-origin.ts +17 -0
- package/src/agent/subagent-completion-reminder.ts +14 -1
- package/src/agent/subagent-drain.ts +2 -0
- package/src/agent/subagents.ts +21 -7
- package/src/agent/tools/channel-disengage.ts +66 -0
- package/src/agent/tools/channel-log.ts +3 -2
- package/src/agent/tools/spawn-subagent.ts +25 -5
- package/src/agent/tools/subagent-output.ts +13 -1
- package/src/bundled-plugins/github-cli-auth/git-askpass.ts +65 -0
- package/src/bundled-plugins/github-cli-auth/git-command.ts +492 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +97 -36
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/memory-logger.ts +7 -0
- package/src/bundled-plugins/researcher/researcher.ts +14 -11
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +1 -0
- package/src/channels/adapters/line-channel-resolver.ts +129 -0
- package/src/channels/adapters/line-classify.ts +80 -0
- package/src/channels/adapters/line-format.ts +11 -0
- package/src/channels/adapters/line.ts +350 -0
- package/src/channels/engagement.ts +4 -2
- package/src/channels/manager.ts +65 -6
- package/src/channels/router.ts +186 -41
- package/src/channels/schema.ts +6 -1
- package/src/cli/channel.ts +112 -1
- package/src/cli/cron.ts +22 -4
- package/src/cli/oauth-callbacks.ts +5 -4
- package/src/config/providers.ts +62 -0
- package/src/cron/consumer.ts +33 -0
- package/src/cron/count-state.ts +208 -0
- package/src/cron/index.ts +4 -17
- package/src/cron/list.ts +24 -6
- package/src/cron/scheduler.ts +84 -9
- package/src/cron/schema.ts +100 -13
- package/src/doctor/channel-checks.ts +28 -0
- package/src/hostd/daemon.ts +14 -6
- package/src/hostd/protocol.ts +6 -2
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +36 -3
- package/src/init/line-auth.ts +98 -0
- package/src/init/models-dev.ts +1 -0
- package/src/init/run-owner-claim.ts +1 -0
- package/src/init/validate-api-key.ts +2 -0
- package/src/inspect/label.ts +1 -0
- package/src/permissions/match-rule.ts +28 -12
- package/src/permissions/resolve.ts +8 -1
- package/src/role-claim/match-rule.ts +5 -1
- package/src/run/index.ts +41 -4
- package/src/secrets/line-store.ts +112 -0
- package/src/secrets/oauth-xai.ts +1 -1
- package/src/secrets/schema.ts +25 -0
- package/src/server/index.ts +17 -4
- package/src/shared/protocol.ts +4 -1
- package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
- package/src/skills/typeclaw-channels/SKILL.md +153 -0
- package/src/skills/typeclaw-config/SKILL.md +54 -184
- package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
- package/src/skills/typeclaw-cron/SKILL.md +68 -14
- package/src/skills/typeclaw-permissions/SKILL.md +3 -3
- package/typeclaw.schema.json +167 -3
package/src/channels/manager.ts
CHANGED
|
@@ -4,12 +4,14 @@ import { join } from 'node:path'
|
|
|
4
4
|
import type { PermissionService } from '@/permissions'
|
|
5
5
|
import type { GithubSecretsBlock } from '@/secrets'
|
|
6
6
|
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
7
|
+
import { SecretsLineCredentialStore } from '@/secrets/line-store'
|
|
7
8
|
import { SecretsBackend } from '@/secrets/storage'
|
|
8
9
|
import type { Stream } from '@/stream'
|
|
9
10
|
|
|
10
11
|
import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/discord-bot'
|
|
11
12
|
import { createGithubAdapter, type GithubAdapter } from './adapters/github'
|
|
12
13
|
import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
|
|
14
|
+
import { createLineAdapter, type LineAdapter } from './adapters/line'
|
|
13
15
|
import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
|
|
14
16
|
import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
|
|
15
17
|
import type { GithubTokenBridge } from './github-token-bridge'
|
|
@@ -66,6 +68,7 @@ export type ChannelManagerOptions = {
|
|
|
66
68
|
createDiscordAdapter?: typeof createDiscordBotAdapter
|
|
67
69
|
createGithubAdapter?: typeof createGithubAdapter
|
|
68
70
|
createKakaotalkAdapter?: typeof createKakaotalkAdapter
|
|
71
|
+
createLineAdapter?: typeof createLineAdapter
|
|
69
72
|
createSlackAdapter?: typeof createSlackBotAdapter
|
|
70
73
|
createTelegramAdapter?: typeof createTelegramBotAdapter
|
|
71
74
|
// Wake-up gate: forwarded to the router, which calls
|
|
@@ -111,7 +114,13 @@ export type ChannelManager = {
|
|
|
111
114
|
reload: () => Promise<{ started: string[]; stopped: string[]; restartRequired: string[] }>
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
type AnyAdapter =
|
|
117
|
+
type AnyAdapter =
|
|
118
|
+
| DiscordBotAdapter
|
|
119
|
+
| GithubAdapter
|
|
120
|
+
| LineAdapter
|
|
121
|
+
| KakaotalkAdapter
|
|
122
|
+
| SlackBotAdapter
|
|
123
|
+
| TelegramBotAdapter
|
|
115
124
|
|
|
116
125
|
// Credential signature is the comparison key for credential-rotation
|
|
117
126
|
// detection on reload. Discord and Telegram each use a single bot token;
|
|
@@ -143,6 +152,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
143
152
|
const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
|
|
144
153
|
const createGithub = options.createGithubAdapter ?? createGithubAdapter
|
|
145
154
|
const createKakaotalk = options.createKakaotalkAdapter ?? createKakaotalkAdapter
|
|
155
|
+
const createLine = options.createLineAdapter ?? createLineAdapter
|
|
146
156
|
const createSlackAdapter = options.createSlackAdapter ?? createSlackBotAdapter
|
|
147
157
|
const createTelegramAdapter = options.createTelegramAdapter ?? createTelegramBotAdapter
|
|
148
158
|
|
|
@@ -160,6 +170,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
const buildCredentialSignature = (name: AdapterId): { signature: string; missing: string[] } => {
|
|
173
|
+
if (name === 'line') return buildLineSignature(options.agentDir)
|
|
163
174
|
if (name === 'kakaotalk') return buildKakaotalkSignature(options.agentDir)
|
|
164
175
|
if (name === 'github') return buildGithubSignature(options.agentDir)
|
|
165
176
|
const requiredEnvs = TOKEN_ENV[name]
|
|
@@ -198,6 +209,15 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
198
209
|
selfAliasesRef: () => router.getSelfAliases(),
|
|
199
210
|
})
|
|
200
211
|
}
|
|
212
|
+
if (name === 'line') {
|
|
213
|
+
return createLine({
|
|
214
|
+
router,
|
|
215
|
+
configRef: () => options.channelsConfigRef()[name] ?? cfg,
|
|
216
|
+
logger,
|
|
217
|
+
selfAliasesRef: () => router.getSelfAliases(),
|
|
218
|
+
credentialsStore: createContainerLineCredentialStore(options.agentDir, env),
|
|
219
|
+
})
|
|
220
|
+
}
|
|
201
221
|
if (name === 'kakaotalk') {
|
|
202
222
|
return createKakaotalk({
|
|
203
223
|
router,
|
|
@@ -335,7 +355,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
335
355
|
await runSerially(name, () => stopAdapter(name))
|
|
336
356
|
stopped.push(name)
|
|
337
357
|
} else if (signature !== current.credentialSignature) {
|
|
338
|
-
const reason = name === 'kakaotalk' ? 'credential rotation' : 'token rotation'
|
|
358
|
+
const reason = name === 'kakaotalk' || name === 'line' ? 'credential rotation' : 'token rotation'
|
|
339
359
|
restartRequired.push(`${name} (${reason})`)
|
|
340
360
|
}
|
|
341
361
|
}
|
|
@@ -346,10 +366,10 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
346
366
|
}
|
|
347
367
|
}
|
|
348
368
|
|
|
349
|
-
// Token-based adapters only. KakaoTalk's credentials live in
|
|
350
|
-
// secrets.json#channels
|
|
351
|
-
// buildKakaotalkSignature instead.
|
|
352
|
-
const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk' | 'github'>, readonly string[]> = {
|
|
369
|
+
// Token-based adapters only. KakaoTalk's and LINE's credentials live in
|
|
370
|
+
// secrets.json#channels.<adapter>, not in env, so they go through
|
|
371
|
+
// buildKakaotalkSignature / buildLineSignature instead.
|
|
372
|
+
const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk' | 'line' | 'github'>, readonly string[]> = {
|
|
353
373
|
'discord-bot': ['DISCORD_BOT_TOKEN'],
|
|
354
374
|
'slack-bot': ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
|
|
355
375
|
'telegram-bot': ['TELEGRAM_BOT_TOKEN'],
|
|
@@ -387,6 +407,36 @@ function buildKakaotalkSignature(agentDir: string): { signature: string; missing
|
|
|
387
407
|
}
|
|
388
408
|
}
|
|
389
409
|
|
|
410
|
+
function createContainerLineCredentialStore(agentDir: string, env: NodeJS.ProcessEnv): SecretsLineCredentialStore {
|
|
411
|
+
const hostdUrl = env.TYPECLAW_HOSTD_URL
|
|
412
|
+
const restartToken = env.TYPECLAW_HOSTD_TOKEN
|
|
413
|
+
const containerName = env.TYPECLAW_CONTAINER_NAME
|
|
414
|
+
if (!hostdUrl || !restartToken || !containerName) {
|
|
415
|
+
throw new Error('LINE credentials require TYPECLAW_HOSTD_URL, TYPECLAW_HOSTD_TOKEN, and TYPECLAW_CONTAINER_NAME')
|
|
416
|
+
}
|
|
417
|
+
return new SecretsLineCredentialStore({
|
|
418
|
+
mode: 'container',
|
|
419
|
+
secretsPath: join(agentDir, 'secrets.json'),
|
|
420
|
+
hostdUrl,
|
|
421
|
+
restartToken,
|
|
422
|
+
containerName,
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function buildLineSignature(agentDir: string): { signature: string; missing: string[] } {
|
|
427
|
+
const path = join(agentDir, 'secrets.json')
|
|
428
|
+
try {
|
|
429
|
+
const block = new SecretsBackend(path).tryReadChannelsSync()?.line
|
|
430
|
+
if (!isLineCredentialBlock(block)) {
|
|
431
|
+
return { signature: '', missing: ['secrets.json#channels.line'] }
|
|
432
|
+
}
|
|
433
|
+
const digest = createHash('sha256').update(JSON.stringify(block)).digest('hex')
|
|
434
|
+
return { signature: `secrets.json#channels.line@sha256:${digest}`, missing: [] }
|
|
435
|
+
} catch (err) {
|
|
436
|
+
return { signature: '', missing: [`secrets.json#channels.line (${describe(err)})`] }
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
390
440
|
function buildGithubSignature(agentDir: string): { signature: string; missing: string[] } {
|
|
391
441
|
const block = readGithubSecrets(agentDir)
|
|
392
442
|
if (block === null) return { signature: '', missing: ['secrets.json#channels.github'] }
|
|
@@ -422,6 +472,15 @@ function isKakaoCredentialBlock(value: unknown): value is { accounts: Record<str
|
|
|
422
472
|
)
|
|
423
473
|
}
|
|
424
474
|
|
|
475
|
+
function isLineCredentialBlock(value: unknown): value is { accounts: Record<string, unknown> } {
|
|
476
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
477
|
+
if (!('accounts' in value)) return false
|
|
478
|
+
const accounts = value.accounts
|
|
479
|
+
return (
|
|
480
|
+
typeof accounts === 'object' && accounts !== null && !Array.isArray(accounts) && Object.keys(accounts).length > 0
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
|
|
425
484
|
function describe(err: unknown): string {
|
|
426
485
|
return err instanceof Error ? err.message : String(err)
|
|
427
486
|
}
|
package/src/channels/router.ts
CHANGED
|
@@ -595,6 +595,16 @@ type LiveSession = {
|
|
|
595
595
|
// send was attempted. Compared by `turnSeq` so a stale value can't leak across
|
|
596
596
|
// turns.
|
|
597
597
|
skipLockedSendTurn: number | null
|
|
598
|
+
// Stamped with the current `turnSeq` by `clearSticky` (called from the
|
|
599
|
+
// `channel_disengage` tool). Read in `send()`'s post-delivery grant block: a
|
|
600
|
+
// successful outbound normally re-grants sticky credit to the turn's authors,
|
|
601
|
+
// which would silently re-arm the engagement the model just dropped if it
|
|
602
|
+
// acks ("ok, backing off") via `channel_reply` in the same turn. When this
|
|
603
|
+
// matches the live `turnSeq`, the grant is suppressed so disengage stays
|
|
604
|
+
// binding for the rest of the turn — the same "commit to it within the turn"
|
|
605
|
+
// shape as the skip lock. `null` when no disengage has been recorded.
|
|
606
|
+
// Compared by `turnSeq` so a stale value can't leak across turns.
|
|
607
|
+
disengagedTurn: number | null
|
|
598
608
|
// Captured by drain() at batch dequeue; read+cleared by send() on the
|
|
599
609
|
// first tool-source send of the turn. The anchor decision (delay
|
|
600
610
|
// threshold + intervening-observed check) is evaluated at SEND time
|
|
@@ -618,6 +628,16 @@ type LiveSession = {
|
|
|
618
628
|
// "is this a multi-human group". Read by composeTurnPrompt().
|
|
619
629
|
multiHumanGroup: boolean
|
|
620
630
|
membershipFetch: Promise<MembershipCount | null> | null
|
|
631
|
+
// Provider soft-error (`stopReason: 'error'`) captured during the current
|
|
632
|
+
// turn, deferred to turn-end. Upstream surfaces transient errors (e.g.
|
|
633
|
+
// `server_is_overloaded`) MID-turn and the turn often recovers, replying
|
|
634
|
+
// seconds later — posting the "⚠️ provider failed" notice on the spot strands
|
|
635
|
+
// a false failure above the eventual real reply. So the listener only RECORDS
|
|
636
|
+
// here (stamped with the firing turnSeq; raw text logged for operators); the
|
|
637
|
+
// turn-end finally posts `safeMessage` ONLY if no reply landed, else discards.
|
|
638
|
+
// First error per turn wins. Keyed by `turnSeq` so a stale record from a
|
|
639
|
+
// crashed prior turn can't leak into the next.
|
|
640
|
+
pendingProviderError: { turnSeq: number; safeMessage: string } | null
|
|
621
641
|
destroyed: boolean
|
|
622
642
|
unsubProviderErrors: (() => void) | null
|
|
623
643
|
unsubTypingActivity: (() => void) | null
|
|
@@ -807,6 +827,14 @@ export type ChannelRouter = {
|
|
|
807
827
|
| { kind: 'recorded'; keyId: string }
|
|
808
828
|
| { kind: 'recorded-after-send'; keyId: string }
|
|
809
829
|
| { kind: 'no-live-session' }
|
|
830
|
+
// Force-clear every sticky credit for one channel key. Stickiness normally
|
|
831
|
+
// expires on TTL or is consumed on the next inbound, but in a busy group each
|
|
832
|
+
// reply re-grants a fresh credit, so the bot can stay force-engaged turn after
|
|
833
|
+
// turn even after being told to stop. This is the escape hatch the
|
|
834
|
+
// `channel_disengage` tool calls to drop back to strict mention/reply/dm
|
|
835
|
+
// engagement without waiting out the window. In-memory only,
|
|
836
|
+
// so a later reply re-grants. `cleared` counts the author credits dropped.
|
|
837
|
+
clearSticky: (key: ChannelKey) => { keyId: string; cleared: number }
|
|
810
838
|
// Two-phase boot restart-resume. Call `reserveRestartHandoff(handoff)` BEFORE
|
|
811
839
|
// `channelManager.start()` to install a per-key gate so an inbound that races
|
|
812
840
|
// the adapters coming online coalesces onto the resume instead of competing
|
|
@@ -1466,55 +1494,31 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1466
1494
|
nextPromptMaxTokens: undefined,
|
|
1467
1495
|
skippedTurn: null,
|
|
1468
1496
|
skipLockedSendTurn: null,
|
|
1497
|
+
disengagedTurn: null,
|
|
1469
1498
|
pendingQuoteCandidate: null,
|
|
1470
1499
|
recentEngagedPeerBotTurns: [],
|
|
1471
1500
|
consecutiveEngagedPeerBotTurns: 0,
|
|
1472
1501
|
loopGuardActive: false,
|
|
1473
1502
|
multiHumanGroup: false,
|
|
1474
1503
|
membershipFetch,
|
|
1504
|
+
pendingProviderError: null,
|
|
1475
1505
|
destroyed: false,
|
|
1476
1506
|
unsubProviderErrors: null,
|
|
1477
1507
|
unsubTypingActivity: null,
|
|
1478
1508
|
unsubTodoOutcome: null,
|
|
1479
1509
|
}
|
|
1480
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
1485
|
-
//
|
|
1486
|
-
|
|
1510
|
+
// Raw text is logged on EVERY attempt for operators; the user-facing
|
|
1511
|
+
// notice is deferred. The upstream SDK retries internally, and each retry
|
|
1512
|
+
// emits its own `message_end` with `stopReason: 'error'` — but the turn
|
|
1513
|
+
// frequently recovers and replies anyway. Recording the first error per
|
|
1514
|
+
// turn (keyed by `turnSeq`) lets drain()'s turn-end decide: post the
|
|
1515
|
+
// notice only if no reply landed, suppress it if the turn recovered. This
|
|
1516
|
+
// both dedupes the retry storm AND prevents a stranded false-failure
|
|
1517
|
+
// notice above a successful reply. See `pendingProviderError`.
|
|
1487
1518
|
live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
|
|
1488
1519
|
logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
// can't each slip past the check and enqueue their own notice.
|
|
1492
|
-
if (lastProviderErrorNoticeTurn === live.turnSeq) return
|
|
1493
|
-
lastProviderErrorNoticeTurn = live.turnSeq
|
|
1494
|
-
// A provider soft-error (rate/usage limit, billing, malformed response)
|
|
1495
|
-
// ends the turn with no assistant text, so the human otherwise sees
|
|
1496
|
-
// silence. Surface the REDACTED `safeMessage` (never the raw provider
|
|
1497
|
-
// text, which can carry response bodies / URLs / tokens) via a 'system'
|
|
1498
|
-
// send — the same one-shot bypass path validateChannelTurn uses, so it
|
|
1499
|
-
// lands regardless of per-turn send caps and skips the duplicate guard.
|
|
1500
|
-
void send(
|
|
1501
|
-
{
|
|
1502
|
-
adapter: live.key.adapter,
|
|
1503
|
-
workspace: live.key.workspace,
|
|
1504
|
-
chat: live.key.chat,
|
|
1505
|
-
thread: live.key.thread,
|
|
1506
|
-
text: `⚠️ ${err.safeMessage}`,
|
|
1507
|
-
},
|
|
1508
|
-
{ source: 'system' },
|
|
1509
|
-
)
|
|
1510
|
-
.then((result) => {
|
|
1511
|
-
if (!result.ok) {
|
|
1512
|
-
logger.warn(`[channels] ${live.keyId}: provider-error notice send failed: ${result.error}`)
|
|
1513
|
-
}
|
|
1514
|
-
})
|
|
1515
|
-
.catch((sendErr) => {
|
|
1516
|
-
logger.warn(`[channels] ${live.keyId}: provider-error notice send threw: ${describe(sendErr)}`)
|
|
1517
|
-
})
|
|
1520
|
+
if (live.pendingProviderError?.turnSeq === live.turnSeq) return
|
|
1521
|
+
live.pendingProviderError = { turnSeq: live.turnSeq, safeMessage: err.safeMessage }
|
|
1518
1522
|
})
|
|
1519
1523
|
live.unsubTodoOutcome = created.session.subscribe((event: unknown) => {
|
|
1520
1524
|
const usage = extractTurnUsage(event)
|
|
@@ -1931,6 +1935,56 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1931
1935
|
}
|
|
1932
1936
|
}
|
|
1933
1937
|
|
|
1938
|
+
const maybePostDeferredProviderError = async (
|
|
1939
|
+
live: LiveSession,
|
|
1940
|
+
sentReplyThisTurn: boolean,
|
|
1941
|
+
retryQueued: boolean,
|
|
1942
|
+
): Promise<void> => {
|
|
1943
|
+
const pending = live.pendingProviderError
|
|
1944
|
+
if (pending === null || pending.turnSeq !== live.turnSeq) {
|
|
1945
|
+
live.pendingProviderError = null
|
|
1946
|
+
return
|
|
1947
|
+
}
|
|
1948
|
+
// The turn recovered and replied — the provider blip was transient, so a
|
|
1949
|
+
// failure notice would be a false alarm stranded above the real reply.
|
|
1950
|
+
if (sentReplyThisTurn) {
|
|
1951
|
+
live.pendingProviderError = null
|
|
1952
|
+
return
|
|
1953
|
+
}
|
|
1954
|
+
// An empty-turn retry was queued (validateChannelTurn pushed
|
|
1955
|
+
// EMPTY_TURN_RETRY_NUDGE): the same logical turn re-prompts in the next
|
|
1956
|
+
// drain iteration and may yet recover. Carry the pending error forward,
|
|
1957
|
+
// re-stamping to that iteration's turnSeq (drain does `turnSeq++` once per
|
|
1958
|
+
// iteration, so it will be exactly `turnSeq + 1`). Posting now would strand
|
|
1959
|
+
// a false-failure notice above the retry's eventual reply — the same defect
|
|
1960
|
+
// one logical turn later.
|
|
1961
|
+
if (retryQueued) {
|
|
1962
|
+
live.pendingProviderError = { turnSeq: live.turnSeq + 1, safeMessage: pending.safeMessage }
|
|
1963
|
+
return
|
|
1964
|
+
}
|
|
1965
|
+
live.pendingProviderError = null
|
|
1966
|
+
// Genuinely empty turn with no retry left: surface the REDACTED
|
|
1967
|
+
// `safeMessage` (never raw provider text, which can carry response bodies /
|
|
1968
|
+
// URLs / tokens) via a 'system' send — the one-shot bypass path that lands
|
|
1969
|
+
// regardless of per-turn send caps — so the human doesn't just see silence.
|
|
1970
|
+
const result = await send(
|
|
1971
|
+
{
|
|
1972
|
+
adapter: live.key.adapter,
|
|
1973
|
+
workspace: live.key.workspace,
|
|
1974
|
+
chat: live.key.chat,
|
|
1975
|
+
thread: live.key.thread,
|
|
1976
|
+
text: `⚠️ ${pending.safeMessage}`,
|
|
1977
|
+
},
|
|
1978
|
+
{ source: 'system' },
|
|
1979
|
+
).catch((sendErr) => {
|
|
1980
|
+
logger.warn(`[channels] ${live.keyId}: provider-error notice send threw: ${describe(sendErr)}`)
|
|
1981
|
+
return null
|
|
1982
|
+
})
|
|
1983
|
+
if (result !== null && !result.ok) {
|
|
1984
|
+
logger.warn(`[channels] ${live.keyId}: provider-error notice send failed: ${result.error}`)
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1934
1988
|
const drain = async (live: LiveSession): Promise<void> => {
|
|
1935
1989
|
if (live.draining || live.destroyed) return
|
|
1936
1990
|
live.draining = true
|
|
@@ -1948,6 +2002,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1948
2002
|
live.currentTurnAttachments = collectTurnAttachments(observed, batch)
|
|
1949
2003
|
|
|
1950
2004
|
if (batch.length > 0) {
|
|
2005
|
+
// A fresh user batch starts a NEW logical turn. Drop any provider
|
|
2006
|
+
// error carried forward from a prior turn's empty-turn retry: the
|
|
2007
|
+
// drain loop splices promptQueue AND pendingSystemReminders together,
|
|
2008
|
+
// so a user message arriving while a retry nudge is pending coalesces
|
|
2009
|
+
// into this iteration. Without this clear, the carried error (stamped
|
|
2010
|
+
// `turnSeq + 1`) would match this fresh turn and post the prior turn's
|
|
2011
|
+
// notice misattributed to an unrelated new message. Carry-forward stays
|
|
2012
|
+
// intact for genuinely reminder-only iterations (batch.length === 0),
|
|
2013
|
+
// which skip this branch.
|
|
2014
|
+
live.pendingProviderError = null
|
|
1951
2015
|
live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
|
|
1952
2016
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1953
2017
|
live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
|
|
@@ -2010,10 +2074,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2010
2074
|
logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
|
|
2011
2075
|
const promptStart = now()
|
|
2012
2076
|
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
2077
|
+
const emptyTurnRetriesBeforePrompt = live.emptyTurnRetries
|
|
2013
2078
|
const engageAddPromises = live.currentTurnEngageReactions
|
|
2014
2079
|
live.turnSeq++
|
|
2015
2080
|
live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
|
|
2016
2081
|
live.skipLockedSendTurn = null
|
|
2082
|
+
live.disengagedTurn = null
|
|
2017
2083
|
live.policyDeniedToolSendsThisTurn.clear()
|
|
2018
2084
|
resetReviewTurn(live.sessionId)
|
|
2019
2085
|
const isRealUserTurn = batch.length > 0
|
|
@@ -2030,6 +2096,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2030
2096
|
} finally {
|
|
2031
2097
|
const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
|
|
2032
2098
|
if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
|
|
2099
|
+
const emptyTurnRetryQueued = live.emptyTurnRetries > emptyTurnRetriesBeforePrompt
|
|
2100
|
+
await maybePostDeferredProviderError(live, sentReplyThisTurn, emptyTurnRetryQueued)
|
|
2033
2101
|
await fireSessionTurnEnd(live)
|
|
2034
2102
|
}
|
|
2035
2103
|
await fireSessionIdle(live)
|
|
@@ -2389,8 +2457,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2389
2457
|
|
|
2390
2458
|
const hasBotParticipated = (live: LiveSession): boolean => {
|
|
2391
2459
|
if (live.successfulChannelSends > 0) return true
|
|
2460
|
+
// Only OUR own prefetched history counts as participation — matching any
|
|
2461
|
+
// bot here let a PEER bot's buffered message flip botInThread=true,
|
|
2462
|
+
// neutralizing the replyToOtherMessageId suppressor and engaging us in a
|
|
2463
|
+
// thread aimed at another bot. Self id is unavailable during the startup
|
|
2464
|
+
// identity race; then this falls back to successfulChannelSends, which is
|
|
2465
|
+
// safe (conservative: we just don't claim participation we can't prove).
|
|
2466
|
+
const selfId = resolveSelfIdentity(live.key)?.id
|
|
2467
|
+
if (selfId === undefined) return false
|
|
2392
2468
|
for (const item of live.contextBuffer) {
|
|
2393
|
-
if (item.
|
|
2469
|
+
if (item.authorId === selfId) return true
|
|
2394
2470
|
}
|
|
2395
2471
|
return false
|
|
2396
2472
|
}
|
|
@@ -2847,7 +2923,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2847
2923
|
return { ok: false, error: `no adapter registered for "${msg.adapter}"`, code: 'no-adapter' }
|
|
2848
2924
|
}
|
|
2849
2925
|
|
|
2850
|
-
|
|
2926
|
+
// Strip leaked `<think>` reasoning off the message itself, up front, so the
|
|
2927
|
+
// stripped text flows through EVERY downstream consumer: the flood check,
|
|
2928
|
+
// the duplicate guard, the quote-anchor prepend, and the adapter callback.
|
|
2929
|
+
// A body that was nothing but a think block collapses to undefined and is
|
|
2930
|
+
// delivered as a text-less send (attachments, if any, still go through).
|
|
2931
|
+
if (msg.text !== undefined) {
|
|
2932
|
+
msg = { ...msg, text: normalizeSendText(msg.text) }
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
const authoredText = msg.text
|
|
2851
2936
|
if (authoredText !== undefined) {
|
|
2852
2937
|
const flood = checkOutboundFlood(authoredText)
|
|
2853
2938
|
if (!flood.ok) return { ok: false, error: OUTBOUND_FLOOD_ERROR, code: 'outbound-flood' }
|
|
@@ -3023,8 +3108,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3023
3108
|
// land after it. Discord and Telegram treat the extra tick as a
|
|
3024
3109
|
// no-op refresh of their already-armed (auto-expiring) indicators.
|
|
3025
3110
|
if (live.typingTimer) void fireTyping(live, 'tick')
|
|
3111
|
+
// Disengage is binding for the rest of the turn: if the model dropped
|
|
3112
|
+
// sticky via `channel_disengage` this turn, a same-turn ack reply must NOT
|
|
3113
|
+
// silently re-grant the credit it just cleared. Skipped only for the live
|
|
3114
|
+
// turn (matched by `turnSeq`); the next turn re-grants normally.
|
|
3115
|
+
const disengagedThisTurn = live.disengagedTurn !== null && live.disengagedTurn === live.turnSeq
|
|
3026
3116
|
const adapterConfig = options.configForAdapter(msg.adapter)
|
|
3027
|
-
if (adapterConfig) {
|
|
3117
|
+
if (adapterConfig && !disengagedThisTurn) {
|
|
3028
3118
|
const targetIds = Array.from(
|
|
3029
3119
|
live.currentTurnAuthorIds.size > 0 ? live.currentTurnAuthorIds : live.lastTurnAuthorIds,
|
|
3030
3120
|
)
|
|
@@ -3632,6 +3722,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3632
3722
|
ok: boolean
|
|
3633
3723
|
durationMs: number
|
|
3634
3724
|
error?: string
|
|
3725
|
+
hasRecoverableOutput?: boolean
|
|
3635
3726
|
},
|
|
3636
3727
|
): { kind: 'delivered'; keyId: string } => {
|
|
3637
3728
|
const adapter = live.keyId.split(':', 1)[0] ?? ''
|
|
@@ -3641,6 +3732,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3641
3732
|
ok: args.ok,
|
|
3642
3733
|
durationMs: args.durationMs,
|
|
3643
3734
|
...(args.error !== undefined ? { error: args.error } : {}),
|
|
3735
|
+
...(args.hasRecoverableOutput === true ? { hasRecoverableOutput: true } : {}),
|
|
3644
3736
|
channel: true,
|
|
3645
3737
|
adapter,
|
|
3646
3738
|
})
|
|
@@ -3673,6 +3765,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3673
3765
|
ok: boolean
|
|
3674
3766
|
durationMs: number
|
|
3675
3767
|
error?: string
|
|
3768
|
+
hasRecoverableOutput?: boolean
|
|
3676
3769
|
channelKey?: { adapter: string; workspace: string; chat: string; thread: string | null }
|
|
3677
3770
|
}): { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' } => {
|
|
3678
3771
|
for (const live of liveSessions.values()) {
|
|
@@ -3727,6 +3820,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3727
3820
|
return { kind: 'no-live-session' }
|
|
3728
3821
|
}
|
|
3729
3822
|
|
|
3823
|
+
const clearSticky = (key: ChannelKey): { keyId: string; cleared: number } => {
|
|
3824
|
+
const keyId = channelKeyId(key)
|
|
3825
|
+
const cleared = stickyLedger.clear(keyId)
|
|
3826
|
+
// Arm the same-turn re-grant guard so a subsequent ack reply this turn does
|
|
3827
|
+
// not re-grant the credit just cleared (see `disengagedTurn`). No-op when
|
|
3828
|
+
// the key has no live session — the ledger clear above still stands.
|
|
3829
|
+
const live = liveSessions.get(keyId)
|
|
3830
|
+
if (live && !live.destroyed) live.disengagedTurn = live.turnSeq
|
|
3831
|
+
logger.info(`[channels] ${keyId} sticky cleared count=${cleared}`)
|
|
3832
|
+
return { keyId, cleared }
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3730
3835
|
return {
|
|
3731
3836
|
route,
|
|
3732
3837
|
send,
|
|
@@ -3768,6 +3873,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3768
3873
|
getSelfAliases: computeSelfAliases,
|
|
3769
3874
|
injectSubagentCompletionReminder,
|
|
3770
3875
|
markTurnSkipped,
|
|
3876
|
+
clearSticky,
|
|
3771
3877
|
reserveRestartHandoff,
|
|
3772
3878
|
resumeRestartHandoff,
|
|
3773
3879
|
stop,
|
|
@@ -3987,6 +4093,22 @@ function composeTurnPrompt(
|
|
|
3987
4093
|
// the `## Recent context (not addressed to you …)` header — mislabeling the
|
|
3988
4094
|
// one line the model is supposed to answer as context it should ignore.
|
|
3989
4095
|
if (batch.length > 0) {
|
|
4096
|
+
// The `## Current message` header is a WITHIN-turn label, but it also gets
|
|
4097
|
+
// persisted into the transcript — so after many turns the model sees a
|
|
4098
|
+
// chain of turns each headed "addressed to you" and a weak model collapses
|
|
4099
|
+
// that into "only the latest turn exists", denying it can see what the user
|
|
4100
|
+
// said earlier (those turns are in its own message history). This note
|
|
4101
|
+
// re-anchors the header as turn-local. Conditional "if earlier turns
|
|
4102
|
+
// appear" wording so it is not a false premise on turn 1 (a fresh session
|
|
4103
|
+
// has no history). No leading `>` — that is this repo's quote-anchor syntax
|
|
4104
|
+
// and would read as quoted content. Worded to NOT contain the literal
|
|
4105
|
+
// `## Current message` heading — a pinned test asserts its absence on
|
|
4106
|
+
// reminder-only drains, so it must stay batch-gated and substring-free.
|
|
4107
|
+
parts.push(
|
|
4108
|
+
'Note: if earlier turns appear above, they are real conversation history you can use.',
|
|
4109
|
+
"The heading below marks this turn's new message, not the only message that may exist.",
|
|
4110
|
+
'',
|
|
4111
|
+
)
|
|
3990
4112
|
parts.push(batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)')
|
|
3991
4113
|
for (const b of batch) {
|
|
3992
4114
|
parts.push(formatInboundPromptLines(b, adapter))
|
|
@@ -4050,6 +4172,7 @@ function formatAuthorReference(adapter: AdapterId, authorId: string, authorName:
|
|
|
4050
4172
|
case 'github':
|
|
4051
4173
|
return displayName.startsWith('@') ? displayName : `@${displayName}`
|
|
4052
4174
|
case 'telegram-bot':
|
|
4175
|
+
case 'line':
|
|
4053
4176
|
case 'kakaotalk':
|
|
4054
4177
|
return displayName
|
|
4055
4178
|
}
|
|
@@ -4316,10 +4439,32 @@ export function resolveLiveSessionForCommand(
|
|
|
4316
4439
|
return { kind: 'none' }
|
|
4317
4440
|
}
|
|
4318
4441
|
|
|
4442
|
+
// Strips leaked `<think>…</think>` reasoning from outbound message text. Some
|
|
4443
|
+
// models (DeepSeek-R1 / Qwen-QwQ family) emit chain-of-thought inline as a
|
|
4444
|
+
// literal `<think>` span in `delta.content` rather than a dedicated `thinking`
|
|
4445
|
+
// content block, so it lands verbatim in the assistant body and — without this
|
|
4446
|
+
// — gets posted to the channel (production: a reasoning paragraph leaked into a
|
|
4447
|
+
// Slack thread). The whole block is removed, not just the tags.
|
|
4448
|
+
//
|
|
4449
|
+
// THINK_BLOCK_RE matches closed blocks (case-insensitive, attribute-tolerant,
|
|
4450
|
+
// multi-line). DANGLING_THINK_RE catches an UNCLOSED trailing `<think>` (model
|
|
4451
|
+
// ran out of budget mid-reasoning) by dropping open-tag-to-end. The final pass
|
|
4452
|
+
// collapses excision-left blank-line runs and trims.
|
|
4453
|
+
const THINK_BLOCK_RE = /<think\b[^>]*>[\s\S]*?<\/think\s*>/gi
|
|
4454
|
+
const DANGLING_THINK_RE = /<think\b[^>]*>[\s\S]*$/i
|
|
4455
|
+
|
|
4456
|
+
export function stripThinkBlocks(text: string): string {
|
|
4457
|
+
const withoutBlocks = text.replace(THINK_BLOCK_RE, '').replace(DANGLING_THINK_RE, '')
|
|
4458
|
+
return withoutBlocks.replace(/\n{3,}/g, '\n\n').trim()
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4319
4461
|
function normalizeSendText(text: string | undefined): string | undefined {
|
|
4320
4462
|
if (text === undefined) return undefined
|
|
4321
|
-
|
|
4322
|
-
|
|
4463
|
+
// Strip before the empty-collapse so a turn that was ONLY a think block
|
|
4464
|
+
// resolves to `undefined` (suppressed) instead of posting an empty shell.
|
|
4465
|
+
const stripped = stripThinkBlocks(text)
|
|
4466
|
+
if (stripped === '') return undefined
|
|
4467
|
+
return stripped
|
|
4323
4468
|
}
|
|
4324
4469
|
|
|
4325
4470
|
function recordSendTimestamp(live: LiveSession, sendKey: string, ts: number): number {
|
package/src/channels/schema.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
export const ADAPTER_IDS = ['discord-bot', 'github', 'kakaotalk', 'slack-bot', 'telegram-bot'] as const
|
|
3
|
+
export const ADAPTER_IDS = ['discord-bot', 'github', 'line', 'kakaotalk', 'slack-bot', 'telegram-bot'] as const
|
|
4
4
|
|
|
5
5
|
export type AdapterId = (typeof ADAPTER_IDS)[number]
|
|
6
6
|
|
|
@@ -254,6 +254,11 @@ export const channelsSchema = z
|
|
|
254
254
|
.object({
|
|
255
255
|
'discord-bot': adapterSchema.optional(),
|
|
256
256
|
github: githubChannelSchema.optional(),
|
|
257
|
+
// LINE is a personal-account channel like KakaoTalk: plain-text only,
|
|
258
|
+
// alias-only engagement (no native @-mention), credentials in
|
|
259
|
+
// secrets.json#channels.line (not env). Unlike KakaoTalk it has no
|
|
260
|
+
// token-renewal cron — it persists a long-lived auth token + certificate.
|
|
261
|
+
line: adapterSchema.optional(),
|
|
257
262
|
kakaotalk: adapterSchema.optional(),
|
|
258
263
|
'slack-bot': adapterSchema.optional(),
|
|
259
264
|
'telegram-bot': adapterSchema.optional(),
|