typeclaw 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +183 -62
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +57 -45
package/src/channels/router.ts
CHANGED
|
@@ -6,7 +6,9 @@ import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
|
6
6
|
import { createSession, type AgentSession } from '@/agent'
|
|
7
7
|
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
8
8
|
import { createCommandRegistry } from '@/commands'
|
|
9
|
+
import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
|
|
9
10
|
import type { HookBus } from '@/plugin'
|
|
11
|
+
import { extractClaimCode } from '@/role-claim'
|
|
10
12
|
|
|
11
13
|
import { decideEngagement, grantStickyForReplyTargets, StickyLedger, type EngagementDecision } from './engagement'
|
|
12
14
|
import {
|
|
@@ -75,6 +77,18 @@ export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
|
|
|
75
77
|
export const SESSION_IDLE_MS = 30 * 60 * 1000
|
|
76
78
|
export const SESSION_GC_INTERVAL_MS = 60 * 1000
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Maximum age of the last engaged inbound before the next inbound triggers a fresh session.
|
|
82
|
+
* Set to the LLM provider's KV-cache TTL (5 min) so the new session's system prompt is
|
|
83
|
+
* guaranteed to be a cache hit on the provider side.
|
|
84
|
+
*
|
|
85
|
+
* Unlike SESSION_IDLE_MS (which evicts the in-memory entry without rollover), this constant
|
|
86
|
+
* triggers a full tearDownLive + recreate on the next engaged inbound. The old session's
|
|
87
|
+
* transcript is preserved on disk; only the in-memory live entry and sessions.json pointer
|
|
88
|
+
* are replaced.
|
|
89
|
+
*/
|
|
90
|
+
export const SESSION_FRESHNESS_TTL_MS = 5 * 60 * 1000
|
|
91
|
+
|
|
78
92
|
// Watchdog ceiling for ensureLive's full async chain (resolve names →
|
|
79
93
|
// fetch membership → open session manager → persist mapping → prefetch
|
|
80
94
|
// history). A legitimate cold-start completes in well under a second;
|
|
@@ -154,6 +168,12 @@ export type CreateSessionForChannel = (params: {
|
|
|
154
168
|
existingSessionFile?: string
|
|
155
169
|
participants: readonly ChannelParticipant[]
|
|
156
170
|
origin: SessionOrigin
|
|
171
|
+
// Mutable holder the router updates per turn (with the current turn's
|
|
172
|
+
// lastInboundAuthorId, participants, etc.) so tool.before events stamp
|
|
173
|
+
// the live actor identity rather than the cold-start snapshot. The
|
|
174
|
+
// factory is expected to pass this through to createSession as
|
|
175
|
+
// `options.originRef`.
|
|
176
|
+
originRef: { current: SessionOrigin | undefined }
|
|
157
177
|
}) => Promise<{
|
|
158
178
|
session: AgentSession
|
|
159
179
|
sessionId: string
|
|
@@ -201,6 +221,7 @@ type LiveSession = {
|
|
|
201
221
|
getTranscriptPath: (() => string | undefined) | undefined
|
|
202
222
|
participants: ChannelParticipant[]
|
|
203
223
|
resolvedNames: ResolvedChannelNames
|
|
224
|
+
originRef: { current: SessionOrigin | undefined }
|
|
204
225
|
promptQueue: QueuedInbound[]
|
|
205
226
|
contextBuffer: ObservedInbound[]
|
|
206
227
|
draining: boolean
|
|
@@ -308,6 +329,46 @@ export type CreateChannelRouterOptions = {
|
|
|
308
329
|
// Test seam: bound the session.idle hook chain so the timeout path is
|
|
309
330
|
// exercisable in tens of milliseconds instead of the 30s default.
|
|
310
331
|
sessionIdleTimeoutMs?: number
|
|
332
|
+
// Wake-up gate: every inbound is gated by `permissions.has(partialOrigin,
|
|
333
|
+
// 'channel.respond')` BEFORE ensureLive. Required by the production
|
|
334
|
+
// wiring (manager.ts forwards `pluginsLoaded.permissions`); defaulted
|
|
335
|
+
// to a grant-all service inside the factory so existing direct test
|
|
336
|
+
// instantiations don't need to inject one. The default is intentionally
|
|
337
|
+
// permissive — the manager-to-router seam is the place where production
|
|
338
|
+
// injection is enforced; direct-router tests opt into gate semantics by
|
|
339
|
+
// passing their own service.
|
|
340
|
+
permissions?: PermissionService
|
|
341
|
+
// Optional role-claim handler. When set, the router intercepts DM
|
|
342
|
+
// inbounds whose text contains a claim code BEFORE the channel.respond
|
|
343
|
+
// gate, hands the inbound to the handler, and short-circuits the normal
|
|
344
|
+
// route path (no session creation, no permission check, no engagement
|
|
345
|
+
// pipeline). The handler returns the reply text the router should send
|
|
346
|
+
// back over the same chat, or null to fall through to normal routing
|
|
347
|
+
// when no pending claim window matches.
|
|
348
|
+
claimHandler?: ClaimHandler
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export type ClaimHandlerInput = {
|
|
352
|
+
adapter: ChannelKey['adapter']
|
|
353
|
+
workspace: string
|
|
354
|
+
chat: string
|
|
355
|
+
isDm: boolean
|
|
356
|
+
authorId: string
|
|
357
|
+
text: string
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export type ClaimHandlerOutcome =
|
|
361
|
+
| { kind: 'consumed'; reply: string }
|
|
362
|
+
| { kind: 'fail'; reply: string }
|
|
363
|
+
| { kind: 'fallthrough' }
|
|
364
|
+
|
|
365
|
+
export type ClaimHandler = (input: ClaimHandlerInput) => Promise<ClaimHandlerOutcome>
|
|
366
|
+
|
|
367
|
+
const GRANT_ALL_PERMISSIONS: PermissionService = {
|
|
368
|
+
has: () => true,
|
|
369
|
+
resolveRole: () => 'owner',
|
|
370
|
+
describe: () => ({ role: 'owner', permissions: [CORE_PERMISSIONS.channelRespond] }),
|
|
371
|
+
replaceRoles: () => {},
|
|
311
372
|
}
|
|
312
373
|
|
|
313
374
|
export function createChannelRouter(options: CreateChannelRouterOptions): ChannelRouter {
|
|
@@ -317,6 +378,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
317
378
|
const resolveChannelNamesTimeoutMs = options.resolveChannelNamesTimeoutMs ?? RESOLVE_CHANNEL_NAMES_TIMEOUT_MS
|
|
318
379
|
const fetchHistoryTimeoutMs = options.fetchHistoryTimeoutMs ?? FETCH_HISTORY_TIMEOUT_MS
|
|
319
380
|
const sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS
|
|
381
|
+
const permissions = options.permissions ?? GRANT_ALL_PERMISSIONS
|
|
382
|
+
const claimHandler = options.claimHandler
|
|
320
383
|
const liveSessions = new Map<string, LiveSession>()
|
|
321
384
|
const creating = new Map<string, Promise<LiveSession>>()
|
|
322
385
|
const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
|
|
@@ -355,6 +418,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
355
418
|
|
|
356
419
|
let mappings: ChannelSessionRecord[] | null = null
|
|
357
420
|
let loadOnce: Promise<void> | null = null
|
|
421
|
+
let persistChain: Promise<void> = Promise.resolve()
|
|
358
422
|
|
|
359
423
|
const ensureLoaded = async (): Promise<void> => {
|
|
360
424
|
if (mappings !== null) return
|
|
@@ -368,12 +432,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
368
432
|
|
|
369
433
|
const persist = async (): Promise<void> => {
|
|
370
434
|
if (mappings === null) return
|
|
371
|
-
|
|
435
|
+
persistChain = persistChain.then(async () => {
|
|
436
|
+
if (mappings === null) return
|
|
437
|
+
await saveChannelSessions(options.agentDir, mappings, logger)
|
|
438
|
+
})
|
|
439
|
+
await persistChain
|
|
372
440
|
}
|
|
373
441
|
|
|
374
442
|
const createForChannel: CreateSessionForChannel =
|
|
375
443
|
options.createSessionForChannel ??
|
|
376
|
-
(async ({ key, existingSessionId, existingSessionFile, origin }) => {
|
|
444
|
+
(async ({ key, existingSessionId, existingSessionFile, origin, originRef }) => {
|
|
377
445
|
const sessionDir = options.sessionDir ?? `${options.agentDir}/sessions`
|
|
378
446
|
const sessionManager =
|
|
379
447
|
existingSessionId !== undefined
|
|
@@ -382,6 +450,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
382
450
|
const session = await createSession({
|
|
383
451
|
sessionManager,
|
|
384
452
|
origin,
|
|
453
|
+
originRef,
|
|
385
454
|
})
|
|
386
455
|
const sessionId = sessionManager.getSessionId()
|
|
387
456
|
void key
|
|
@@ -476,10 +545,44 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
476
545
|
return membership
|
|
477
546
|
}
|
|
478
547
|
|
|
479
|
-
const ensureLive = async (
|
|
548
|
+
const ensureLive = async (
|
|
549
|
+
key: ChannelKey,
|
|
550
|
+
triggeringMessageId?: string,
|
|
551
|
+
triggeringAuthorId?: string,
|
|
552
|
+
): Promise<LiveSession> => {
|
|
480
553
|
const keyId = channelKeyId(key)
|
|
481
554
|
const existing = liveSessions.get(keyId)
|
|
482
|
-
if (existing && !existing.destroyed)
|
|
555
|
+
if (existing && !existing.destroyed) {
|
|
556
|
+
const idleMs = now() - existing.lastInboundAt
|
|
557
|
+
if (idleMs > SESSION_FRESHNESS_TTL_MS) {
|
|
558
|
+
logger.info(`[channels] ${keyId}: stale-rollover (live: ${idleMs}ms idle)`)
|
|
559
|
+
await tearDownLive(existing)
|
|
560
|
+
liveSessions.delete(keyId)
|
|
561
|
+
if (mappings) {
|
|
562
|
+
const idx = mappings.findIndex(
|
|
563
|
+
(s) =>
|
|
564
|
+
s.adapter === key.adapter &&
|
|
565
|
+
s.workspace === key.workspace &&
|
|
566
|
+
s.chat === key.chat &&
|
|
567
|
+
(s.thread ?? null) === (key.thread ?? null),
|
|
568
|
+
)
|
|
569
|
+
if (idx >= 0) {
|
|
570
|
+
const prev = mappings[idx]!
|
|
571
|
+
mappings[idx] = {
|
|
572
|
+
adapter: prev.adapter,
|
|
573
|
+
workspace: prev.workspace,
|
|
574
|
+
chat: prev.chat,
|
|
575
|
+
thread: prev.thread,
|
|
576
|
+
participants: prev.participants,
|
|
577
|
+
lastInboundAt: 0,
|
|
578
|
+
}
|
|
579
|
+
await persist()
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
} else {
|
|
583
|
+
return existing
|
|
584
|
+
}
|
|
585
|
+
}
|
|
483
586
|
|
|
484
587
|
const inFlight = creating.get(keyId)
|
|
485
588
|
if (inFlight) return inFlight
|
|
@@ -487,14 +590,51 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
487
590
|
const promise = (async () => {
|
|
488
591
|
await ensureLoaded()
|
|
489
592
|
const record = mappings ? findRecord(mappings, key) : undefined
|
|
490
|
-
|
|
593
|
+
let resolvedRecord = record
|
|
594
|
+
if (
|
|
595
|
+
record?.sessionId !== undefined &&
|
|
596
|
+
existing === undefined &&
|
|
597
|
+
now() - (record.lastInboundAt ?? 0) > SESSION_FRESHNESS_TTL_MS
|
|
598
|
+
) {
|
|
599
|
+
const idleMs = now() - (record.lastInboundAt ?? 0)
|
|
600
|
+
logger.info(`[channels] ${keyId}: stale-rollover (persisted: ${idleMs}ms idle)`)
|
|
601
|
+
resolvedRecord = {
|
|
602
|
+
adapter: record.adapter,
|
|
603
|
+
workspace: record.workspace,
|
|
604
|
+
chat: record.chat,
|
|
605
|
+
thread: record.thread,
|
|
606
|
+
participants: record.participants,
|
|
607
|
+
lastInboundAt: 0,
|
|
608
|
+
}
|
|
609
|
+
if (mappings) {
|
|
610
|
+
const idx = mappings.findIndex(
|
|
611
|
+
(s) =>
|
|
612
|
+
s.adapter === key.adapter &&
|
|
613
|
+
s.workspace === key.workspace &&
|
|
614
|
+
s.chat === key.chat &&
|
|
615
|
+
(s.thread ?? null) === (key.thread ?? null),
|
|
616
|
+
)
|
|
617
|
+
if (idx >= 0) {
|
|
618
|
+
mappings[idx] = resolvedRecord
|
|
619
|
+
await persist()
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const phase = resolvedRecord?.sessionId === undefined ? 'cold-start' : 'rehydrate'
|
|
491
624
|
logger.info(`[channels] ${keyId}: ensureLive begin (${phase})`)
|
|
492
|
-
const participants = (
|
|
625
|
+
const participants = (resolvedRecord?.participants ?? []) as ChannelParticipant[]
|
|
493
626
|
const membershipFetch = warmMembership(key)
|
|
494
627
|
const resolvedNames = await resolveChannelNames(key)
|
|
495
628
|
logger.info(`[channels] ${keyId}: ensureLive resolved-names`)
|
|
496
629
|
const membership = await membershipForPrompt(key, membershipFetch)
|
|
497
630
|
logger.info(`[channels] ${keyId}: ensureLive resolved-membership`)
|
|
631
|
+
// The session-creation origin is what the resource loader sees when it
|
|
632
|
+
// renders the role/permissions block into the system prompt. It must
|
|
633
|
+
// include the triggering author so author-scoped roles
|
|
634
|
+
// (`slack:T/C author:U_ME`) resolve to the same role here that the
|
|
635
|
+
// channel.respond gate just admitted on. Per-turn updates after this
|
|
636
|
+
// point are handled by `originRef.current = buildLiveOrigin(live)`
|
|
637
|
+
// before each prompt() call.
|
|
498
638
|
const origin: SessionOrigin = {
|
|
499
639
|
kind: 'channel',
|
|
500
640
|
adapter: key.adapter,
|
|
@@ -503,18 +643,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
503
643
|
chat: key.chat,
|
|
504
644
|
...(resolvedNames.chatName !== undefined ? { chatName: resolvedNames.chatName } : {}),
|
|
505
645
|
thread: key.thread,
|
|
646
|
+
...(triggeringAuthorId !== undefined ? { lastInboundAuthorId: triggeringAuthorId } : {}),
|
|
506
647
|
participants,
|
|
507
648
|
...(membership !== null ? { membership } : {}),
|
|
508
649
|
}
|
|
509
650
|
|
|
510
|
-
const isColdStart =
|
|
651
|
+
const isColdStart = resolvedRecord?.sessionId === undefined
|
|
652
|
+
|
|
653
|
+
// The router writes into this holder before every prompt() so the
|
|
654
|
+
// tool wrappers' getOrigin() sees the current-turn origin.
|
|
655
|
+
const originRef: { current: SessionOrigin | undefined } = { current: origin }
|
|
511
656
|
|
|
512
657
|
const created = await createForChannel({
|
|
513
658
|
key,
|
|
514
|
-
...(
|
|
515
|
-
...(
|
|
659
|
+
...(resolvedRecord?.sessionId ? { existingSessionId: resolvedRecord.sessionId } : {}),
|
|
660
|
+
...(resolvedRecord?.sessionFile ? { existingSessionFile: resolvedRecord.sessionFile } : {}),
|
|
516
661
|
participants,
|
|
517
662
|
origin,
|
|
663
|
+
originRef,
|
|
518
664
|
})
|
|
519
665
|
logger.info(`[channels] ${keyId}: ensureLive session-created sessionId=${created.sessionId}`)
|
|
520
666
|
|
|
@@ -526,6 +672,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
526
672
|
thread: key.thread,
|
|
527
673
|
sessionId: created.sessionId,
|
|
528
674
|
...(transcriptPath ? { sessionFile: basename(transcriptPath) } : {}),
|
|
675
|
+
lastInboundAt: now(),
|
|
529
676
|
participants,
|
|
530
677
|
}
|
|
531
678
|
if (mappings) {
|
|
@@ -553,6 +700,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
553
700
|
getTranscriptPath: created.getTranscriptPath,
|
|
554
701
|
participants,
|
|
555
702
|
resolvedNames,
|
|
703
|
+
originRef,
|
|
556
704
|
promptQueue: [],
|
|
557
705
|
contextBuffer: [],
|
|
558
706
|
draining: false,
|
|
@@ -697,8 +845,6 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
697
845
|
await persist()
|
|
698
846
|
}
|
|
699
847
|
|
|
700
|
-
const regenerateOrigin = (live: LiveSession): SessionOrigin => buildLiveOrigin(live)
|
|
701
|
-
|
|
702
848
|
const fireTyping = async (live: LiveSession, phase: 'tick' | 'stop'): Promise<void> => {
|
|
703
849
|
const callbacks = typingCallbacks.get(live.key.adapter)
|
|
704
850
|
if (!callbacks || callbacks.size === 0) return
|
|
@@ -860,13 +1006,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
860
1006
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
861
1007
|
if (batch.length > 0) live.consecutiveSends.clear()
|
|
862
1008
|
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
|
|
1009
|
+
// Update the live origin holder so this turn's tool.before events
|
|
1010
|
+
// carry the current actor's id. The DefaultResourceLoader still
|
|
1011
|
+
// renders the session-creation origin into the system prompt (v0.2
|
|
1012
|
+
// work to regenerate that per-turn); but permission gating off
|
|
1013
|
+
// `lastInboundAuthorId` happens in the tool layer and now sees the
|
|
1014
|
+
// live value.
|
|
1015
|
+
live.originRef.current = buildLiveOrigin(live)
|
|
870
1016
|
|
|
871
1017
|
// Bracketing logs around the LLM call so a hung prompt() is
|
|
872
1018
|
// diagnosable from logs alone (we see prompting without prompted).
|
|
@@ -906,6 +1052,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
906
1052
|
const elapsedSinceFirst = t - live.firstUnprocessedAt
|
|
907
1053
|
const wait = Math.max(0, Math.min(baseWait, MAX_DEBOUNCE_MS - elapsedSinceFirst))
|
|
908
1054
|
live.lastInboundAt = t
|
|
1055
|
+
if (mappings) {
|
|
1056
|
+
const idx = mappings.findIndex(
|
|
1057
|
+
(s) =>
|
|
1058
|
+
s.adapter === live.key.adapter &&
|
|
1059
|
+
s.workspace === live.key.workspace &&
|
|
1060
|
+
s.chat === live.key.chat &&
|
|
1061
|
+
(s.thread ?? null) === (live.key.thread ?? null),
|
|
1062
|
+
)
|
|
1063
|
+
if (idx >= 0) {
|
|
1064
|
+
mappings[idx] = { ...mappings[idx]!, lastInboundAt: t }
|
|
1065
|
+
void persist()
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
909
1068
|
live.debounceTimer = setTimeout(() => {
|
|
910
1069
|
live.debounceTimer = null
|
|
911
1070
|
live.firstUnprocessedAt = 0
|
|
@@ -924,8 +1083,46 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
924
1083
|
thread: event.thread,
|
|
925
1084
|
}
|
|
926
1085
|
|
|
1086
|
+
// Role-claim intercept runs BEFORE the channel.respond gate so the
|
|
1087
|
+
// operator can bootstrap permissions on a fresh agent that has no
|
|
1088
|
+
// role match rules yet. Cheap pre-check: only DMs whose text contains
|
|
1089
|
+
// a `claim-` prefix can be claim attempts, and only when a handler
|
|
1090
|
+
// is registered. Everything else falls straight through to the gate.
|
|
1091
|
+
if (claimHandler !== undefined && event.isDm && extractClaimCode(event.text) !== null) {
|
|
1092
|
+
const outcome = await claimHandler({
|
|
1093
|
+
adapter: event.adapter,
|
|
1094
|
+
workspace: event.workspace,
|
|
1095
|
+
chat: event.chat,
|
|
1096
|
+
isDm: event.isDm,
|
|
1097
|
+
authorId: event.authorId,
|
|
1098
|
+
text: event.text,
|
|
1099
|
+
})
|
|
1100
|
+
if (outcome.kind !== 'fallthrough') {
|
|
1101
|
+
logger.info(
|
|
1102
|
+
`[channels] ${channelKeyId(key)}: claim ${outcome.kind} author=${event.authorId} id=${event.externalMessageId}`,
|
|
1103
|
+
)
|
|
1104
|
+
await send({
|
|
1105
|
+
adapter: event.adapter,
|
|
1106
|
+
workspace: event.workspace,
|
|
1107
|
+
chat: event.chat,
|
|
1108
|
+
thread: event.thread,
|
|
1109
|
+
text: outcome.reply,
|
|
1110
|
+
})
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (isChannelRespondDenied(event)) {
|
|
1116
|
+
logger.info(
|
|
1117
|
+
`[channels] ${channelKeyId(key)}: denied by permissions (channel.respond) author=${event.authorId} id=${event.externalMessageId}`,
|
|
1118
|
+
)
|
|
1119
|
+
return
|
|
1120
|
+
}
|
|
1121
|
+
|
|
927
1122
|
const parsedCommand = commands.parse(event.text)
|
|
928
1123
|
if (parsedCommand !== null) {
|
|
1124
|
+
// Commands are control traffic, not engaged inbounds; if the session is stale,
|
|
1125
|
+
// the next engaged inbound will perform the rollover before prompting.
|
|
929
1126
|
const keyId = channelKeyId(key)
|
|
930
1127
|
if (!commands.has(parsedCommand.name)) {
|
|
931
1128
|
logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
|
|
@@ -940,7 +1137,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
940
1137
|
if (commandResult.kind !== 'not-command') return
|
|
941
1138
|
}
|
|
942
1139
|
|
|
943
|
-
const live = await ensureLive(key, event.externalMessageId)
|
|
1140
|
+
const live = await ensureLive(key, event.externalMessageId, event.authorId)
|
|
944
1141
|
|
|
945
1142
|
const isNewAuthor = !live.participants.some((p) => p.authorId === event.authorId)
|
|
946
1143
|
live.participants = updateParticipants(
|
|
@@ -1010,6 +1207,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1010
1207
|
scheduleDebouncedDrain(live)
|
|
1011
1208
|
}
|
|
1012
1209
|
|
|
1210
|
+
const isChannelRespondDenied = (event: InboundMessage): boolean => {
|
|
1211
|
+
const partial: SessionOrigin = {
|
|
1212
|
+
kind: 'channel',
|
|
1213
|
+
adapter: event.adapter,
|
|
1214
|
+
workspace: event.workspace,
|
|
1215
|
+
chat: event.chat,
|
|
1216
|
+
thread: event.thread,
|
|
1217
|
+
lastInboundAuthorId: event.authorId,
|
|
1218
|
+
}
|
|
1219
|
+
return !permissions.has(partial, CORE_PERMISSIONS.channelRespond)
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1013
1222
|
const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
|
|
1014
1223
|
if (!event.authorIsBot) {
|
|
1015
1224
|
live.recentEngagedPeerBotTurns.length = 0
|
package/src/channels/schema.ts
CHANGED
|
@@ -4,11 +4,6 @@ export const ADAPTER_IDS = ['discord-bot', 'kakaotalk', 'slack-bot', 'telegram-b
|
|
|
4
4
|
|
|
5
5
|
export type AdapterId = (typeof ADAPTER_IDS)[number]
|
|
6
6
|
|
|
7
|
-
const allowRuleSchema = z.string().min(1).refine(isValidAllowRule, {
|
|
8
|
-
message:
|
|
9
|
-
'allow rule must be one of: *, guild:*, guild:<id>, guild:<id>/<channel>, team:*, team:<id>, team:<id>/<channel>, tg:*, tg:<chat_id>, channel:<id>, dm:*, dm:<id>, im:*, im:<id>, kakao:*, kakao:<chat>, kakao:dm/*, kakao:group/*, kakao:open/*',
|
|
10
|
-
})
|
|
11
|
-
|
|
12
7
|
const engagementTriggerSchema = z.enum(['mention', 'reply', 'dm'])
|
|
13
8
|
|
|
14
9
|
const stickinessSchema = z.union([
|
|
@@ -92,8 +87,13 @@ const historySchema = z
|
|
|
92
87
|
},
|
|
93
88
|
})
|
|
94
89
|
|
|
90
|
+
// Deliberately non-strict: a stale on-disk file may still carry the
|
|
91
|
+
// legacy `allow` field (`migrateLegacyConfigShape` lifts it into
|
|
92
|
+
// `roles.member.match[]` on load, but a between-reload window can
|
|
93
|
+
// briefly contain both). Zod silently drops unknown keys here, which is
|
|
94
|
+
// exactly what we want — a hard `.strict()` reject would brick recovery
|
|
95
|
+
// for any user mid-migration.
|
|
95
96
|
const adapterSchema = z.object({
|
|
96
|
-
allow: z.array(allowRuleSchema).default([]),
|
|
97
97
|
engagement: engagementSchema,
|
|
98
98
|
history: historySchema,
|
|
99
99
|
enabled: z.boolean().default(true),
|
|
@@ -118,156 +118,6 @@ export const channelsSchema = z
|
|
|
118
118
|
})
|
|
119
119
|
.default({})
|
|
120
120
|
|
|
121
|
-
export type AllowRule = string
|
|
122
121
|
export type EngagementConfig = z.infer<typeof engagementSchema>
|
|
123
122
|
export type ChannelAdapterConfig = z.infer<typeof adapterSchema>
|
|
124
|
-
export type KakaotalkAdapterConfig = ChannelAdapterConfig
|
|
125
123
|
export type ChannelsConfig = z.infer<typeof channelsSchema>
|
|
126
|
-
|
|
127
|
-
// Discord IDs are numeric snowflakes; Slack IDs start with a single uppercase
|
|
128
|
-
// letter (T for teams, C/D/G for channels) followed by alphanumerics; Telegram
|
|
129
|
-
// chat IDs are signed integers (negative for groups, `-100…` for supergroups
|
|
130
|
-
// and channels); KakaoTalk chat IDs are LOCO-protocol decimal integers
|
|
131
|
-
// (large enough to need BigInt at the protocol layer, but rendered as plain
|
|
132
|
-
// decimal strings here). All shapes are accepted on every adapter so the
|
|
133
|
-
// allow list stays declarative — the runtime ensures only the right adapter
|
|
134
|
-
// ever sees its own IDs.
|
|
135
|
-
const RULE_PATTERNS = [
|
|
136
|
-
/^\*$/,
|
|
137
|
-
// Discord
|
|
138
|
-
/^guild:\*$/,
|
|
139
|
-
/^guild:[0-9]+$/,
|
|
140
|
-
/^guild:[0-9]+\/[0-9]+$/,
|
|
141
|
-
/^dm:\*$/,
|
|
142
|
-
/^dm:[0-9]+$/,
|
|
143
|
-
// Slack
|
|
144
|
-
/^team:\*$/,
|
|
145
|
-
/^team:[A-Z0-9]+$/,
|
|
146
|
-
/^team:[A-Z0-9]+\/[A-Z0-9]+$/,
|
|
147
|
-
/^im:\*$/,
|
|
148
|
-
/^im:[A-Z0-9]+$/,
|
|
149
|
-
// Telegram (`tg:*` admits all chats; `tg:<chat_id>` scopes to one chat —
|
|
150
|
-
// numeric, may be negative). There is no team/guild concept; every chat is
|
|
151
|
-
// identified by its absolute id.
|
|
152
|
-
/^tg:\*$/,
|
|
153
|
-
/^tg:-?[0-9]+$/,
|
|
154
|
-
// KakaoTalk: a single workspace per logged-in account, so the rules scope
|
|
155
|
-
// by chat-type (1:1 / group / open) rather than by workspace. `kakao:*`
|
|
156
|
-
// admits every chat the account can see; `kakao:dm/*`, `kakao:group/*`,
|
|
157
|
-
// `kakao:open/*` admit one chat-type bucket; `kakao:<chat-id>` admits a
|
|
158
|
-
// single chat. The runtime classifies each chat into a bucket based on
|
|
159
|
-
// KakaoChat.type at chat-resolver time and surfaces the bucket via the
|
|
160
|
-
// workspace coordinate.
|
|
161
|
-
/^kakao:\*$/,
|
|
162
|
-
/^kakao:dm\/\*$/,
|
|
163
|
-
/^kakao:group\/\*$/,
|
|
164
|
-
/^kakao:open\/\*$/,
|
|
165
|
-
/^kakao:[0-9]+$/,
|
|
166
|
-
// Shared (channel ids are unique on both platforms)
|
|
167
|
-
/^channel:[A-Z0-9]+$/,
|
|
168
|
-
/^channel:-?[0-9]+$/,
|
|
169
|
-
]
|
|
170
|
-
|
|
171
|
-
function isValidAllowRule(rule: string): boolean {
|
|
172
|
-
return RULE_PATTERNS.some((p) => p.test(rule))
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function isAllowed(rules: readonly AllowRule[], workspace: string, chat: string): boolean {
|
|
176
|
-
for (const rule of rules) {
|
|
177
|
-
if (matchRule(rule, workspace, chat)) return true
|
|
178
|
-
}
|
|
179
|
-
return false
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// `*` → every workspace channel + every DM (catch-all)
|
|
183
|
-
// `guild:*` → every Discord guild channel (no DMs)
|
|
184
|
-
// `guild:G` → every channel in guild G
|
|
185
|
-
// `guild:G/C` → channel C in guild G only
|
|
186
|
-
// `team:*` → every Slack team channel (no DMs)
|
|
187
|
-
// `team:T` → every channel in team T
|
|
188
|
-
// `team:T/C` → channel C in team T only
|
|
189
|
-
// `tg:*` → every Telegram chat (DMs, groups, supergroups, channels)
|
|
190
|
-
// `tg:C` → Telegram chat C only (signed numeric chat id)
|
|
191
|
-
// `channel:C` → channel C in any workspace (IDs are globally unique on
|
|
192
|
-
// Discord/Slack and Telegram chat ids are also globally
|
|
193
|
-
// unique numeric values)
|
|
194
|
-
// `dm:*` → every Discord DM
|
|
195
|
-
// `dm:C` → Discord DM channel C only
|
|
196
|
-
// `im:*` → every Slack DM (im channel)
|
|
197
|
-
// `im:D` → Slack DM channel D only
|
|
198
|
-
// `kakao:*` → every KakaoTalk chat the account is in
|
|
199
|
-
// `kakao:dm/*` → every KakaoTalk 1:1 chat
|
|
200
|
-
// `kakao:group/*` → every KakaoTalk group chat
|
|
201
|
-
// `kakao:open/*` → every KakaoTalk open chat
|
|
202
|
-
// `kakao:<id>` → KakaoTalk chat with the given numeric chat_id
|
|
203
|
-
//
|
|
204
|
-
// `guild:`/`dm:`, `team:`/`im:`, `tg:`, and `kakao:` identify which adapter
|
|
205
|
-
// the rule was written for, but the matcher applies any rule that the
|
|
206
|
-
// (workspace, chat) pair satisfies. That keeps the adapter-side coupling at
|
|
207
|
-
// the schema/UX layer (Slack users write `team:`, Discord users write
|
|
208
|
-
// `guild:`, Telegram users write `tg:`, KakaoTalk users write `kakao:`)
|
|
209
|
-
// without bloating the matching logic. Telegram has no workspace concept;
|
|
210
|
-
// the adapter pins workspace to `'telegram'` so `tg:*` only ever admits
|
|
211
|
-
// Telegram chats. KakaoTalk uses `@kakao-dm` / `@kakao-group` / `@kakao-open`
|
|
212
|
-
// as workspace coordinates so the bucket-* rules are pure prefix matches
|
|
213
|
-
// against `workspace`.
|
|
214
|
-
function matchRule(rule: string, workspace: string, chat: string): boolean {
|
|
215
|
-
// KakaoTalk workspaces accept the global `*` catch-all or any `kakao:`
|
|
216
|
-
// rule. Adapter-specific non-kakao rules (`team:*`, `guild:*`, `dm:*`,
|
|
217
|
-
// `im:*`, `tg:*`) never admit kakao workspaces — those are scoped to
|
|
218
|
-
// their own adapter's coordinate space and would be meaningless here.
|
|
219
|
-
// The init wizard still defaults kakaotalk to the narrower `kakao:dm/*`
|
|
220
|
-
// (group chats with personal accounts are sensitive — every member sees
|
|
221
|
-
// every reply), so opting into `*` is an explicit, per-adapter decision
|
|
222
|
-
// made in `channels.kakaotalk.allow`.
|
|
223
|
-
if (KAKAO_WORKSPACES.has(workspace)) {
|
|
224
|
-
if (rule === '*') return true
|
|
225
|
-
if (rule.startsWith('kakao:')) return matchKakaoRule(rule.slice(6), workspace, chat)
|
|
226
|
-
return false
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (rule === '*') return true
|
|
230
|
-
if (rule.startsWith('kakao:')) return false
|
|
231
|
-
|
|
232
|
-
if (workspace === '@dm') {
|
|
233
|
-
if (rule === 'dm:*' || rule === 'im:*') return true
|
|
234
|
-
if (rule.startsWith('dm:')) return rule.slice(3) === chat
|
|
235
|
-
if (rule.startsWith('im:')) return rule.slice(3) === chat
|
|
236
|
-
if (rule.startsWith('channel:')) return rule.slice(8) === chat
|
|
237
|
-
return false
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (workspace === 'telegram') {
|
|
241
|
-
if (rule === 'tg:*') return true
|
|
242
|
-
if (rule.startsWith('tg:')) return rule.slice(3) === chat
|
|
243
|
-
if (rule.startsWith('channel:')) return rule.slice(8) === chat
|
|
244
|
-
return false
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (rule === 'guild:*' || rule === 'team:*') return true
|
|
248
|
-
if (rule.startsWith('channel:')) return rule.slice(8) === chat
|
|
249
|
-
if (rule.startsWith('guild:')) {
|
|
250
|
-
const body = rule.slice(6)
|
|
251
|
-
const slash = body.indexOf('/')
|
|
252
|
-
if (slash === -1) return body === workspace
|
|
253
|
-
return body.slice(0, slash) === workspace && body.slice(slash + 1) === chat
|
|
254
|
-
}
|
|
255
|
-
if (rule.startsWith('team:')) {
|
|
256
|
-
const body = rule.slice(5)
|
|
257
|
-
const slash = body.indexOf('/')
|
|
258
|
-
if (slash === -1) return body === workspace
|
|
259
|
-
return body.slice(0, slash) === workspace && body.slice(slash + 1) === chat
|
|
260
|
-
}
|
|
261
|
-
return false
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const KAKAO_WORKSPACES = new Set(['@kakao-dm', '@kakao-group', '@kakao-open'])
|
|
265
|
-
|
|
266
|
-
function matchKakaoRule(body: string, workspace: string, chat: string): boolean {
|
|
267
|
-
if (!KAKAO_WORKSPACES.has(workspace)) return false
|
|
268
|
-
if (body === '*') return true
|
|
269
|
-
if (body === 'dm/*') return workspace === '@kakao-dm'
|
|
270
|
-
if (body === 'group/*') return workspace === '@kakao-group'
|
|
271
|
-
if (body === 'open/*') return workspace === '@kakao-open'
|
|
272
|
-
return body === chat
|
|
273
|
-
}
|