typeclaw 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,1570 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import type { AssistantMessage } from '@mariozechner/pi-ai'
|
|
4
|
+
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
5
|
+
|
|
6
|
+
import { createSession, type AgentSession } from '@/agent'
|
|
7
|
+
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
8
|
+
import { createCommandRegistry } from '@/commands'
|
|
9
|
+
import type { HookBus } from '@/plugin'
|
|
10
|
+
|
|
11
|
+
import { decideEngagement, grantStickyForReplyTargets, StickyLedger, type EngagementDecision } from './engagement'
|
|
12
|
+
import {
|
|
13
|
+
MEMBERSHIP_COLD_FETCH_TIMEOUT_MS,
|
|
14
|
+
type MembershipCount,
|
|
15
|
+
type MembershipResolver,
|
|
16
|
+
type MembershipResolverResult,
|
|
17
|
+
} from './membership'
|
|
18
|
+
import { createMembershipCache, type MembershipCache } from './membership-cache'
|
|
19
|
+
import { updateParticipants } from './participants'
|
|
20
|
+
import {
|
|
21
|
+
channelsSessionsPath,
|
|
22
|
+
findRecord,
|
|
23
|
+
loadChannelSessions,
|
|
24
|
+
saveChannelSessions,
|
|
25
|
+
type ChannelSessionRecord,
|
|
26
|
+
} from './persistence'
|
|
27
|
+
import type { ChannelAdapterConfig } from './schema'
|
|
28
|
+
import type {
|
|
29
|
+
ChannelHistoryMessage,
|
|
30
|
+
ChannelKey,
|
|
31
|
+
ChannelNameResolver,
|
|
32
|
+
FetchAttachmentArgs,
|
|
33
|
+
FetchAttachmentCallback,
|
|
34
|
+
FetchAttachmentResult,
|
|
35
|
+
FetchHistoryArgs,
|
|
36
|
+
FetchHistoryResult,
|
|
37
|
+
HistoryCallback,
|
|
38
|
+
InboundMessage,
|
|
39
|
+
OutboundCallback,
|
|
40
|
+
OutboundMessage,
|
|
41
|
+
ResolvedChannelNames,
|
|
42
|
+
SendResult,
|
|
43
|
+
TypingCallback,
|
|
44
|
+
} from './types'
|
|
45
|
+
import { channelKeyId } from './types'
|
|
46
|
+
|
|
47
|
+
export const INITIAL_DEBOUNCE_MS = 600
|
|
48
|
+
export const HOT_DEBOUNCE_MS = 1500
|
|
49
|
+
export const MAX_DEBOUNCE_MS = 4000
|
|
50
|
+
export const HOT_THRESHOLD_MS = 3000
|
|
51
|
+
export const MAX_CONSECUTIVE_ABORTS = 3
|
|
52
|
+
export const CONTEXT_BUFFER_SIZE = 20
|
|
53
|
+
// Discord's typing indicator expires after ~10s; an 8s heartbeat keeps it
|
|
54
|
+
// continuously visible while we debounce + generate without spamming the API.
|
|
55
|
+
export const TYPING_HEARTBEAT_MS = 8000
|
|
56
|
+
// A stuck model call or an agent that never yields should not keep re-arming
|
|
57
|
+
// platform-side typing forever. Slack Assistant status in particular has a
|
|
58
|
+
// documented 2-minute timeout, so repeatedly refreshing it after that point
|
|
59
|
+
// turns a temporary status into a permanent-looking artifact.
|
|
60
|
+
export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
|
|
61
|
+
|
|
62
|
+
// Idle GC: a LiveSession whose `lastInboundAt` is older than
|
|
63
|
+
// SESSION_IDLE_MS gets evicted on the next GC tick. Persistence
|
|
64
|
+
// (channels/sessions.json) is intentionally untouched — the next inbound
|
|
65
|
+
// rehydrates from disk against the same sessionId, so the on-disk
|
|
66
|
+
// transcript continues across the eviction. The point is to free memory
|
|
67
|
+
// (LiveSession holds an open SessionManager + transcript in RAM) and to
|
|
68
|
+
// give the next conversation a fresh start without forcing the user to
|
|
69
|
+
// notice anything. `lastInboundAt` is bumped only by *engaged* inbounds
|
|
70
|
+
// (see scheduleDebouncedDrain), so passive observation alone won't keep
|
|
71
|
+
// a session warm forever — that's intentional. The session is seeded
|
|
72
|
+
// with `now()` at creation (not `0`) so a freshly-created observe-only
|
|
73
|
+
// session gets a full SESSION_IDLE_MS window before its first GC sweep,
|
|
74
|
+
// not a 56-year-old idle reading from `Date.now() - 0`.
|
|
75
|
+
export const SESSION_IDLE_MS = 30 * 60 * 1000
|
|
76
|
+
export const SESSION_GC_INTERVAL_MS = 60 * 1000
|
|
77
|
+
|
|
78
|
+
// Watchdog ceiling for ensureLive's full async chain (resolve names →
|
|
79
|
+
// fetch membership → open session manager → persist mapping → prefetch
|
|
80
|
+
// history). A legitimate cold-start completes in well under a second;
|
|
81
|
+
// values above ~10s are always either a hung Discord REST call or a
|
|
82
|
+
// rate-limited retry storm. 30s leaves headroom for slow disks or a
|
|
83
|
+
// truly large transcript replay without making operator-noticed hangs
|
|
84
|
+
// indistinguishable from normal latency. On timeout the throw evicts
|
|
85
|
+
// the `creating` map entry so the next inbound retries from scratch
|
|
86
|
+
// instead of awaiting the same dead promise forever.
|
|
87
|
+
export const ENSURE_LIVE_TIMEOUT_MS = 30_000
|
|
88
|
+
|
|
89
|
+
// Per-callback ceilings inside the ensureLive chain. The outer watchdog
|
|
90
|
+
// catches the worst case, but per-step timeouts give better log
|
|
91
|
+
// attribution (which step hung) AND graceful degradation: a hung name
|
|
92
|
+
// resolver still lets engagement run on IDs alone, a hung history fetch
|
|
93
|
+
// still lets the agent answer without prefetched context. Both paths
|
|
94
|
+
// loop over registered callbacks and currently `await` each unbounded.
|
|
95
|
+
// 5s matches Discord's median REST p99 with comfortable headroom.
|
|
96
|
+
export const RESOLVE_CHANNEL_NAMES_TIMEOUT_MS = 5_000
|
|
97
|
+
export const FETCH_HISTORY_TIMEOUT_MS = 5_000
|
|
98
|
+
|
|
99
|
+
// Watchdog over the whole session.idle hook chain. The drain loop awaits
|
|
100
|
+
// `fireSessionIdle` between turns; a single hung plugin handler (e.g. a
|
|
101
|
+
// memory-logger awaiting a network call that never resolves) wedges the
|
|
102
|
+
// loop with `live.draining` stuck `true`, which means subsequent mention
|
|
103
|
+
// inbounds enqueue silently and never fire. Observe-decisions still log
|
|
104
|
+
// because engagement runs in `route()` before the draining check, so the
|
|
105
|
+
// symptom from logs alone is "thread receives observed lines forever
|
|
106
|
+
// after the last `prompted elapsed_ms=...`". Bounding the chain here
|
|
107
|
+
// matches the ensureLive watchdog (30s) so a misbehaving plugin
|
|
108
|
+
// degrades the current turn instead of bricking the channel until
|
|
109
|
+
// container restart. Per-handler attribution lives in plugin/hooks.ts.
|
|
110
|
+
export const SESSION_IDLE_TIMEOUT_MS = 30_000
|
|
111
|
+
|
|
112
|
+
// Two-axis loop guard for peer-bot conversation. Peer bots route into
|
|
113
|
+
// engagement under the SAME rules as humans, so a small ring (A→B→C→A) or
|
|
114
|
+
// a fast cascade can otherwise ping-pong without bound. The guard trips
|
|
115
|
+
// when EITHER axis hits its limit and clears on the next human inbound.
|
|
116
|
+
//
|
|
117
|
+
// Why two axes:
|
|
118
|
+
// - The since-human counter catches slow rings that would never fill a
|
|
119
|
+
// 60s window (3 bots replying every 30s = 4 turns/min, never trips a
|
|
120
|
+
// 60s sliding count).
|
|
121
|
+
// - The 60s window catches fast bursts that would never accumulate enough
|
|
122
|
+
// total turns to pressure the since-human counter (a single bot reflex
|
|
123
|
+
// replying to its own mention 5x in 2s).
|
|
124
|
+
//
|
|
125
|
+
// The model receives a non-fatal warning prepended into composeTurnPrompt
|
|
126
|
+
// when tripped; the LLM decides whether to keep replying. Hard interrupt
|
|
127
|
+
// is intentionally not part of v1 (would require pi-coding-agent abort
|
|
128
|
+
// semantics during in-flight tool calls).
|
|
129
|
+
export const PEER_BOT_TURNS_WINDOW_MS = 60_000
|
|
130
|
+
export const MAX_PEER_BOT_TURNS_IN_WINDOW = 5
|
|
131
|
+
export const MAX_CONSECUTIVE_PEER_BOT_TURNS_SINCE_HUMAN = 5
|
|
132
|
+
|
|
133
|
+
export type RouterLogger = {
|
|
134
|
+
info: (msg: string) => void
|
|
135
|
+
warn: (msg: string) => void
|
|
136
|
+
error: (msg: string) => void
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const consoleLogger: RouterLogger = {
|
|
140
|
+
info: (m) => console.log(m),
|
|
141
|
+
warn: (m) => console.warn(m),
|
|
142
|
+
error: (m) => console.error(m),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type CreateSessionForChannel = (params: {
|
|
146
|
+
key: ChannelKey
|
|
147
|
+
existingSessionId?: string
|
|
148
|
+
// Basename of the JSONL file the prior session wrote to, captured at
|
|
149
|
+
// creation time and persisted in channels/sessions.json. Used for
|
|
150
|
+
// reopening — without this, sessionId alone is insufficient because
|
|
151
|
+
// pi-coding-agent prefixes filenames with an ISO timestamp at write time
|
|
152
|
+
// that the UUID does not encode. Optional for forward-compat with v2
|
|
153
|
+
// mappings that predate the `sessionFile` field.
|
|
154
|
+
existingSessionFile?: string
|
|
155
|
+
participants: readonly ChannelParticipant[]
|
|
156
|
+
origin: SessionOrigin
|
|
157
|
+
}) => Promise<{
|
|
158
|
+
session: AgentSession
|
|
159
|
+
sessionId: string
|
|
160
|
+
dispose: () => Promise<void>
|
|
161
|
+
hooks?: HookBus
|
|
162
|
+
getTranscriptPath?: () => string | undefined
|
|
163
|
+
}>
|
|
164
|
+
|
|
165
|
+
export type ConfigForAdapter = (adapter: ChannelKey['adapter']) => ChannelAdapterConfig | undefined
|
|
166
|
+
|
|
167
|
+
type QueuedInbound = {
|
|
168
|
+
text: string
|
|
169
|
+
authorId: string
|
|
170
|
+
authorName: string
|
|
171
|
+
authorIsBot: boolean
|
|
172
|
+
externalMessageId: string
|
|
173
|
+
isBotMention: boolean
|
|
174
|
+
replyToBotMessageId: string | null
|
|
175
|
+
isDm: boolean
|
|
176
|
+
receivedAt: number
|
|
177
|
+
// Original platform timestamp (Slack/Discord), in ms since epoch. Used
|
|
178
|
+
// by composeTurnPrompt to render an ISO 8601 prefix on each line so the
|
|
179
|
+
// model sees when each message was actually posted, not when the router
|
|
180
|
+
// happened to dequeue it. Zero means "unknown" (the formatter omits the
|
|
181
|
+
// prefix for those).
|
|
182
|
+
ts: number
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
type ObservedInbound = {
|
|
186
|
+
text: string
|
|
187
|
+
authorId: string
|
|
188
|
+
authorName: string
|
|
189
|
+
authorIsBot: boolean
|
|
190
|
+
receivedAt: number
|
|
191
|
+
ts: number
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type LiveSession = {
|
|
195
|
+
key: ChannelKey
|
|
196
|
+
keyId: string
|
|
197
|
+
session: AgentSession
|
|
198
|
+
sessionId: string
|
|
199
|
+
dispose: () => Promise<void>
|
|
200
|
+
hooks: HookBus | undefined
|
|
201
|
+
getTranscriptPath: (() => string | undefined) | undefined
|
|
202
|
+
participants: ChannelParticipant[]
|
|
203
|
+
resolvedNames: ResolvedChannelNames
|
|
204
|
+
promptQueue: QueuedInbound[]
|
|
205
|
+
contextBuffer: ObservedInbound[]
|
|
206
|
+
draining: boolean
|
|
207
|
+
debounceTimer: ReturnType<typeof setTimeout> | null
|
|
208
|
+
typingTimer: ReturnType<typeof setInterval> | null
|
|
209
|
+
typingStartedAt: number
|
|
210
|
+
typingTimedOut: boolean
|
|
211
|
+
typingStopPromise: Promise<void> | null
|
|
212
|
+
lastInboundAt: number
|
|
213
|
+
firstUnprocessedAt: number
|
|
214
|
+
currentTurnAuthorId: string | null
|
|
215
|
+
currentTurnAuthorIds: Set<string>
|
|
216
|
+
lastTurnAuthorIds: Set<string>
|
|
217
|
+
consecutiveAborts: number
|
|
218
|
+
// Per-(chat:thread) count of bot messages sent without intervening user
|
|
219
|
+
// input being rendered into the model's context. Reset at the top of each
|
|
220
|
+
// drain() iteration that picks up a non-empty batch (= a new user turn is
|
|
221
|
+
// about to be shown to the model). channel_send reads this BEFORE calling
|
|
222
|
+
// router.send so the hint reflects the position of the about-to-happen send
|
|
223
|
+
// (n-th in a row), nudging the model to yield without forcing it to.
|
|
224
|
+
consecutiveSends: Map<string, number>
|
|
225
|
+
successfulChannelSends: number
|
|
226
|
+
// Loop-guard state. See PEER_BOT_TURNS_WINDOW_MS / MAX_* constants
|
|
227
|
+
// above. Updated in route() on every engaged peer-bot inbound, reset on
|
|
228
|
+
// any human inbound. The two axes (window ring buffer + since-human
|
|
229
|
+
// counter) are independent — either tripping sets `loopGuardActive`
|
|
230
|
+
// until the next human posts. The active flag is read by
|
|
231
|
+
// composeTurnPrompt() and prepended to the user-turn text.
|
|
232
|
+
recentEngagedPeerBotTurns: { authorId: string; ts: number }[]
|
|
233
|
+
consecutiveEngagedPeerBotTurns: number
|
|
234
|
+
loopGuardActive: boolean
|
|
235
|
+
membershipFetch: Promise<MembershipCount | null> | null
|
|
236
|
+
destroyed: boolean
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
type ChannelCommandContext = {
|
|
240
|
+
live: LiveSession
|
|
241
|
+
event: InboundMessage
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type ChannelRouter = {
|
|
245
|
+
route: (event: InboundMessage) => Promise<void>
|
|
246
|
+
send: (msg: OutboundMessage) => Promise<SendResult>
|
|
247
|
+
getConsecutiveSendCount: (target: {
|
|
248
|
+
adapter: ChannelKey['adapter']
|
|
249
|
+
workspace: string
|
|
250
|
+
chat: string
|
|
251
|
+
thread?: string | null
|
|
252
|
+
}) => number
|
|
253
|
+
registerOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
|
|
254
|
+
unregisterOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
|
|
255
|
+
registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
256
|
+
unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
257
|
+
registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
|
|
258
|
+
unregisterChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
|
|
259
|
+
registerMembership: (adapter: ChannelKey['adapter'], resolver: MembershipResolver) => void
|
|
260
|
+
unregisterMembership: (adapter: ChannelKey['adapter'], resolver: MembershipResolver) => void
|
|
261
|
+
registerHistory: (adapter: ChannelKey['adapter'], cb: HistoryCallback) => void
|
|
262
|
+
unregisterHistory: (adapter: ChannelKey['adapter'], cb: HistoryCallback) => void
|
|
263
|
+
fetchHistory: (adapter: ChannelKey['adapter'], args: FetchHistoryArgs) => Promise<FetchHistoryResult>
|
|
264
|
+
registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
|
|
265
|
+
unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
|
|
266
|
+
fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
|
|
267
|
+
// Lowered self-aliases (configured + implicit dir-name). Adapters use
|
|
268
|
+
// this to anchor outbound threading on alias-only inbounds — see
|
|
269
|
+
// slack-bot-classify.ts. Read live so a reload of `alias` propagates
|
|
270
|
+
// to adapters without a restart.
|
|
271
|
+
getSelfAliases: () => readonly string[]
|
|
272
|
+
stop: () => Promise<void>
|
|
273
|
+
liveCount: () => number
|
|
274
|
+
__testing?: {
|
|
275
|
+
flushDebounce: (key: ChannelKey) => Promise<void>
|
|
276
|
+
fireTypingHeartbeat: (key: ChannelKey, phase?: 'tick' | 'stop') => Promise<void>
|
|
277
|
+
fireTypingInterval: (key: ChannelKey) => Promise<void>
|
|
278
|
+
isTypingActive: (key: ChannelKey) => boolean
|
|
279
|
+
runIdleGc: () => Promise<void>
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Returns the additional aliases the agent answers to (beyond the
|
|
284
|
+
// implicit dir-name). Read from the live config every inbound — `alias`
|
|
285
|
+
// is classified `applied` in FIELD_EFFECTS, so a `reload` should change
|
|
286
|
+
// engagement behavior immediately. Defaults to an empty list when not
|
|
287
|
+
// provided, which means alias-based engagement is effectively off (the
|
|
288
|
+
// dir-name is still implicit and added by the router below).
|
|
289
|
+
export type AliasesProvider = () => readonly string[]
|
|
290
|
+
|
|
291
|
+
export type CreateChannelRouterOptions = {
|
|
292
|
+
agentDir: string
|
|
293
|
+
configForAdapter: ConfigForAdapter
|
|
294
|
+
configuredAliases?: AliasesProvider
|
|
295
|
+
createSessionForChannel?: CreateSessionForChannel
|
|
296
|
+
sessionDir?: string
|
|
297
|
+
logger?: RouterLogger
|
|
298
|
+
// Test seam: clock for sticky/debounce/participants. Defaults to Date.now.
|
|
299
|
+
now?: () => number
|
|
300
|
+
// Test seam: override the ensureLive watchdog ceiling so the timeout path
|
|
301
|
+
// is exercisable in <100ms instead of the 30s production default.
|
|
302
|
+
ensureLiveTimeoutMs?: number
|
|
303
|
+
// Test seam: per-callback ceiling for channel name resolvers; mirrors the
|
|
304
|
+
// ensureLive seam so timeout paths can be exercised quickly in tests.
|
|
305
|
+
resolveChannelNamesTimeoutMs?: number
|
|
306
|
+
// Test seam: per-callback ceiling for history fetches.
|
|
307
|
+
fetchHistoryTimeoutMs?: number
|
|
308
|
+
// Test seam: bound the session.idle hook chain so the timeout path is
|
|
309
|
+
// exercisable in tens of milliseconds instead of the 30s default.
|
|
310
|
+
sessionIdleTimeoutMs?: number
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function createChannelRouter(options: CreateChannelRouterOptions): ChannelRouter {
|
|
314
|
+
const logger = options.logger ?? consoleLogger
|
|
315
|
+
const now = options.now ?? Date.now
|
|
316
|
+
const ensureLiveTimeoutMs = options.ensureLiveTimeoutMs ?? ENSURE_LIVE_TIMEOUT_MS
|
|
317
|
+
const resolveChannelNamesTimeoutMs = options.resolveChannelNamesTimeoutMs ?? RESOLVE_CHANNEL_NAMES_TIMEOUT_MS
|
|
318
|
+
const fetchHistoryTimeoutMs = options.fetchHistoryTimeoutMs ?? FETCH_HISTORY_TIMEOUT_MS
|
|
319
|
+
const sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS
|
|
320
|
+
const liveSessions = new Map<string, LiveSession>()
|
|
321
|
+
const creating = new Map<string, Promise<LiveSession>>()
|
|
322
|
+
const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
|
|
323
|
+
const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
|
|
324
|
+
const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
|
|
325
|
+
const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
|
|
326
|
+
const membershipCaches = new Map<ChannelKey['adapter'], MembershipCache>()
|
|
327
|
+
const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
|
|
328
|
+
const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
|
|
329
|
+
const stickyLedger = new StickyLedger()
|
|
330
|
+
const commands = createCommandRegistry<ChannelCommandContext>([
|
|
331
|
+
{
|
|
332
|
+
name: 'stop',
|
|
333
|
+
handler: async ({ live }) => {
|
|
334
|
+
await stopCurrentChannelTurn(live)
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
])
|
|
338
|
+
|
|
339
|
+
// Implicit dir-name alias: agent folder basename matches Docker
|
|
340
|
+
// container name (per AGENTS.md), the typical Discord/Slack bot
|
|
341
|
+
// username, and the natural way the operator refers to the agent.
|
|
342
|
+
// Lowered once at construction since basename(agentDir) doesn't change
|
|
343
|
+
// over the router's lifetime; configured aliases are lowered per-call
|
|
344
|
+
// because they're read from live config.
|
|
345
|
+
const dirAlias = basename(options.agentDir).toLocaleLowerCase()
|
|
346
|
+
const computeSelfAliases = (): readonly string[] => {
|
|
347
|
+
const configured = options.configuredAliases?.() ?? []
|
|
348
|
+
const set = new Set<string>([dirAlias])
|
|
349
|
+
for (const a of configured) {
|
|
350
|
+
const lower = a.toLocaleLowerCase()
|
|
351
|
+
if (lower !== '') set.add(lower)
|
|
352
|
+
}
|
|
353
|
+
return Array.from(set)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let mappings: ChannelSessionRecord[] | null = null
|
|
357
|
+
let loadOnce: Promise<void> | null = null
|
|
358
|
+
|
|
359
|
+
const ensureLoaded = async (): Promise<void> => {
|
|
360
|
+
if (mappings !== null) return
|
|
361
|
+
if (loadOnce === null) {
|
|
362
|
+
loadOnce = loadChannelSessions(options.agentDir, logger).then((records) => {
|
|
363
|
+
mappings = records
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
await loadOnce
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const persist = async (): Promise<void> => {
|
|
370
|
+
if (mappings === null) return
|
|
371
|
+
await saveChannelSessions(options.agentDir, mappings, logger)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const createForChannel: CreateSessionForChannel =
|
|
375
|
+
options.createSessionForChannel ??
|
|
376
|
+
(async ({ key, existingSessionId, existingSessionFile, origin }) => {
|
|
377
|
+
const sessionDir = options.sessionDir ?? `${options.agentDir}/sessions`
|
|
378
|
+
const sessionManager =
|
|
379
|
+
existingSessionId !== undefined
|
|
380
|
+
? tryOpenSessionManager(options.agentDir, sessionDir, existingSessionId, existingSessionFile, logger)
|
|
381
|
+
: SessionManager.create(options.agentDir, sessionDir)
|
|
382
|
+
const session = await createSession({
|
|
383
|
+
sessionManager,
|
|
384
|
+
origin,
|
|
385
|
+
})
|
|
386
|
+
const sessionId = sessionManager.getSessionId()
|
|
387
|
+
void key
|
|
388
|
+
return {
|
|
389
|
+
session,
|
|
390
|
+
sessionId,
|
|
391
|
+
dispose: async () => {
|
|
392
|
+
session.dispose()
|
|
393
|
+
},
|
|
394
|
+
getTranscriptPath: () => sessionManager.getSessionFile(),
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const resolveChannelNames = async (key: ChannelKey): Promise<ResolvedChannelNames> => {
|
|
399
|
+
const resolvers = channelNameResolvers.get(key.adapter)
|
|
400
|
+
if (!resolvers || resolvers.size === 0) return {}
|
|
401
|
+
const snapshot = Array.from(resolvers)
|
|
402
|
+
const merged: ResolvedChannelNames = {}
|
|
403
|
+
for (const resolver of snapshot) {
|
|
404
|
+
try {
|
|
405
|
+
const result = await raceWithTimeout(
|
|
406
|
+
resolver(key),
|
|
407
|
+
resolveChannelNamesTimeoutMs,
|
|
408
|
+
`[channels] ${channelKeyId(key)}: name resolver`,
|
|
409
|
+
)
|
|
410
|
+
if (result.chatName !== undefined && merged.chatName === undefined) merged.chatName = result.chatName
|
|
411
|
+
if (result.workspaceName !== undefined && merged.workspaceName === undefined) {
|
|
412
|
+
merged.workspaceName = result.workspaceName
|
|
413
|
+
}
|
|
414
|
+
} catch (err) {
|
|
415
|
+
logger.warn(`[channels] name resolver threw for ${channelKeyId(key)}: ${describe(err)}`)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return merged
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const readMembership = (key: ChannelKey): MembershipCount | null => {
|
|
422
|
+
if (key.workspace === '@dm') return dmMembership(now())
|
|
423
|
+
return membershipCaches.get(key.adapter)?.get(key) ?? null
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const warmMembership = (key: ChannelKey): Promise<MembershipCount | null> | null => {
|
|
427
|
+
if (key.workspace === '@dm') return Promise.resolve(dmMembership(now()))
|
|
428
|
+
const cache = membershipCaches.get(key.adapter)
|
|
429
|
+
if (cache === undefined) return null
|
|
430
|
+
return cache.warmUp(key)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const resolveThroughRegisteredMembership = async (key: ChannelKey): Promise<MembershipResolverResult> => {
|
|
434
|
+
const resolvers = membershipResolvers.get(key.adapter)
|
|
435
|
+
if (!resolvers || resolvers.size === 0) return { kind: 'transient' }
|
|
436
|
+
const snapshot = Array.from(resolvers)
|
|
437
|
+
let lastFailure: MembershipResolverResult = { kind: 'transient' }
|
|
438
|
+
for (const resolver of snapshot) {
|
|
439
|
+
const result = await resolver(key)
|
|
440
|
+
if ('humans' in result) return result
|
|
441
|
+
lastFailure = result
|
|
442
|
+
}
|
|
443
|
+
return lastFailure
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const membershipForPrompt = async (
|
|
447
|
+
key: ChannelKey,
|
|
448
|
+
fetchPromise: Promise<MembershipCount | null> | null,
|
|
449
|
+
): Promise<MembershipCount | null> => {
|
|
450
|
+
if (key.workspace === '@dm') return dmMembership(now())
|
|
451
|
+
const cached = readMembership(key)
|
|
452
|
+
if (cached !== null) return cached
|
|
453
|
+
if (fetchPromise === null) return null
|
|
454
|
+
return await withMembershipTimeout(fetchPromise, key, logger)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const membershipForEngagement = async (live: LiveSession): Promise<MembershipCount | null> => {
|
|
458
|
+
if (live.key.workspace === '@dm') return dmMembership(now())
|
|
459
|
+
const cache = membershipCaches.get(live.key.adapter)
|
|
460
|
+
if (cache === undefined) return null
|
|
461
|
+
|
|
462
|
+
const cached = cache.read(live.key)
|
|
463
|
+
if (cached.kind === 'hit') return cached.membership
|
|
464
|
+
if (cached.kind === 'stale') {
|
|
465
|
+
void cache.warmUp(live.key).catch((err) => {
|
|
466
|
+
logger.warn(`[channels] membership refresh failed for ${live.keyId}: ${describe(err)}`)
|
|
467
|
+
})
|
|
468
|
+
return cached.membership
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const fetchPromise = live.membershipFetch ?? warmMembership(live.key)
|
|
472
|
+
live.membershipFetch = fetchPromise
|
|
473
|
+
if (fetchPromise === null) return null
|
|
474
|
+
const membership = await withMembershipTimeout(fetchPromise, live.key, logger)
|
|
475
|
+
if (live.membershipFetch === fetchPromise) live.membershipFetch = null
|
|
476
|
+
return membership
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const ensureLive = async (key: ChannelKey, triggeringMessageId?: string): Promise<LiveSession> => {
|
|
480
|
+
const keyId = channelKeyId(key)
|
|
481
|
+
const existing = liveSessions.get(keyId)
|
|
482
|
+
if (existing && !existing.destroyed) return existing
|
|
483
|
+
|
|
484
|
+
const inFlight = creating.get(keyId)
|
|
485
|
+
if (inFlight) return inFlight
|
|
486
|
+
|
|
487
|
+
const promise = (async () => {
|
|
488
|
+
await ensureLoaded()
|
|
489
|
+
const record = mappings ? findRecord(mappings, key) : undefined
|
|
490
|
+
const phase = record?.sessionId === undefined ? 'cold-start' : 'rehydrate'
|
|
491
|
+
logger.info(`[channels] ${keyId}: ensureLive begin (${phase})`)
|
|
492
|
+
const participants = (record?.participants ?? []) as ChannelParticipant[]
|
|
493
|
+
const membershipFetch = warmMembership(key)
|
|
494
|
+
const resolvedNames = await resolveChannelNames(key)
|
|
495
|
+
logger.info(`[channels] ${keyId}: ensureLive resolved-names`)
|
|
496
|
+
const membership = await membershipForPrompt(key, membershipFetch)
|
|
497
|
+
logger.info(`[channels] ${keyId}: ensureLive resolved-membership`)
|
|
498
|
+
const origin: SessionOrigin = {
|
|
499
|
+
kind: 'channel',
|
|
500
|
+
adapter: key.adapter,
|
|
501
|
+
workspace: key.workspace,
|
|
502
|
+
...(resolvedNames.workspaceName !== undefined ? { workspaceName: resolvedNames.workspaceName } : {}),
|
|
503
|
+
chat: key.chat,
|
|
504
|
+
...(resolvedNames.chatName !== undefined ? { chatName: resolvedNames.chatName } : {}),
|
|
505
|
+
thread: key.thread,
|
|
506
|
+
participants,
|
|
507
|
+
...(membership !== null ? { membership } : {}),
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const isColdStart = record?.sessionId === undefined
|
|
511
|
+
|
|
512
|
+
const created = await createForChannel({
|
|
513
|
+
key,
|
|
514
|
+
...(record?.sessionId ? { existingSessionId: record.sessionId } : {}),
|
|
515
|
+
...(record?.sessionFile ? { existingSessionFile: record.sessionFile } : {}),
|
|
516
|
+
participants,
|
|
517
|
+
origin,
|
|
518
|
+
})
|
|
519
|
+
logger.info(`[channels] ${keyId}: ensureLive session-created sessionId=${created.sessionId}`)
|
|
520
|
+
|
|
521
|
+
const transcriptPath = created.getTranscriptPath?.()
|
|
522
|
+
const persistedRecord: ChannelSessionRecord = {
|
|
523
|
+
adapter: key.adapter,
|
|
524
|
+
workspace: key.workspace,
|
|
525
|
+
chat: key.chat,
|
|
526
|
+
thread: key.thread,
|
|
527
|
+
sessionId: created.sessionId,
|
|
528
|
+
...(transcriptPath ? { sessionFile: basename(transcriptPath) } : {}),
|
|
529
|
+
participants,
|
|
530
|
+
}
|
|
531
|
+
if (mappings) {
|
|
532
|
+
const idx = mappings.findIndex(
|
|
533
|
+
(s) =>
|
|
534
|
+
s.adapter === key.adapter &&
|
|
535
|
+
s.workspace === key.workspace &&
|
|
536
|
+
s.chat === key.chat &&
|
|
537
|
+
(s.thread ?? null) === (key.thread ?? null),
|
|
538
|
+
)
|
|
539
|
+
if (idx >= 0) mappings[idx] = persistedRecord
|
|
540
|
+
else mappings.push(persistedRecord)
|
|
541
|
+
} else {
|
|
542
|
+
mappings = [persistedRecord]
|
|
543
|
+
}
|
|
544
|
+
await persist()
|
|
545
|
+
|
|
546
|
+
const live: LiveSession = {
|
|
547
|
+
key,
|
|
548
|
+
keyId,
|
|
549
|
+
session: created.session,
|
|
550
|
+
sessionId: created.sessionId,
|
|
551
|
+
dispose: created.dispose,
|
|
552
|
+
hooks: created.hooks,
|
|
553
|
+
getTranscriptPath: created.getTranscriptPath,
|
|
554
|
+
participants,
|
|
555
|
+
resolvedNames,
|
|
556
|
+
promptQueue: [],
|
|
557
|
+
contextBuffer: [],
|
|
558
|
+
draining: false,
|
|
559
|
+
debounceTimer: null,
|
|
560
|
+
typingTimer: null,
|
|
561
|
+
typingStartedAt: 0,
|
|
562
|
+
typingTimedOut: false,
|
|
563
|
+
typingStopPromise: null,
|
|
564
|
+
lastInboundAt: now(),
|
|
565
|
+
firstUnprocessedAt: 0,
|
|
566
|
+
currentTurnAuthorId: null,
|
|
567
|
+
currentTurnAuthorIds: new Set(),
|
|
568
|
+
lastTurnAuthorIds: new Set(),
|
|
569
|
+
consecutiveAborts: 0,
|
|
570
|
+
consecutiveSends: new Map(),
|
|
571
|
+
successfulChannelSends: 0,
|
|
572
|
+
recentEngagedPeerBotTurns: [],
|
|
573
|
+
consecutiveEngagedPeerBotTurns: 0,
|
|
574
|
+
loopGuardActive: false,
|
|
575
|
+
membershipFetch,
|
|
576
|
+
destroyed: false,
|
|
577
|
+
}
|
|
578
|
+
liveSessions.set(keyId, live)
|
|
579
|
+
|
|
580
|
+
if (isColdStart) {
|
|
581
|
+
const adapterConfig = options.configForAdapter(key.adapter)
|
|
582
|
+
if (adapterConfig) {
|
|
583
|
+
await prefetchChannelContext(live, adapterConfig, triggeringMessageId)
|
|
584
|
+
logger.info(`[channels] ${keyId}: ensureLive prefetched-context`)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
logger.info(`[channels] ${keyId}: ensureLive done (${phase})`)
|
|
589
|
+
return live
|
|
590
|
+
})()
|
|
591
|
+
|
|
592
|
+
creating.set(keyId, promise)
|
|
593
|
+
try {
|
|
594
|
+
return await raceWithTimeout(promise, ensureLiveTimeoutMs, `[channels] ${keyId} ensureLive`)
|
|
595
|
+
} catch (err) {
|
|
596
|
+
// The orphaned `promise` may still settle eventually; that's OK because
|
|
597
|
+
// the only side effect it produces post-timeout is a `liveSessions.set`,
|
|
598
|
+
// which the next inbound's existence-check short-circuit at the top of
|
|
599
|
+
// ensureLive will treat as a usable warm session — strictly better than
|
|
600
|
+
// a permanent silent drop. The caller (route() in this file, ultimately
|
|
601
|
+
// the adapter's outer catch) sees the timeout error and logs it.
|
|
602
|
+
logger.error(`[channels] ${keyId}: ensureLive failed: ${describe(err)}`)
|
|
603
|
+
throw err
|
|
604
|
+
} finally {
|
|
605
|
+
creating.delete(keyId)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const prefetchChannelContext = async (
|
|
610
|
+
live: LiveSession,
|
|
611
|
+
adapterConfig: ChannelAdapterConfig,
|
|
612
|
+
triggeringMessageId: string | undefined,
|
|
613
|
+
): Promise<void> => {
|
|
614
|
+
const prefetch = adapterConfig.history.prefetch
|
|
615
|
+
const isThread = live.key.thread !== null
|
|
616
|
+
const head = isThread ? prefetch.thread.head : 0
|
|
617
|
+
const tail = isThread ? prefetch.thread.tail : prefetch.channel.tail
|
|
618
|
+
if (head === 0 && tail === 0) return
|
|
619
|
+
|
|
620
|
+
// One fetch per cold start. We always pass the live thread when present so
|
|
621
|
+
// we get the thread-scoped history; channel cold starts pass `thread: null`
|
|
622
|
+
// so we get the channel scrollback. The router's contract is oldest-first,
|
|
623
|
+
// which lets us slice [head] + [tail] without re-sorting. We over-request
|
|
624
|
+
// by one (head + tail + 1) so we can detect "exactly head + tail" without
|
|
625
|
+
// emitting a misleading elision marker for a zero-length gap.
|
|
626
|
+
const requested = head + tail + 1
|
|
627
|
+
const result = await fetchHistory(live.key.adapter, {
|
|
628
|
+
chat: live.key.chat,
|
|
629
|
+
thread: live.key.thread,
|
|
630
|
+
limit: requested,
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
if (!result.ok) {
|
|
634
|
+
logger.warn(`[channels] ${live.keyId}: prefetch skipped (history fetch failed: ${result.error})`)
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Drop the engaging message itself if it appears in the history result.
|
|
639
|
+
// Without this, the model would see the same message twice — once in
|
|
640
|
+
// "Recent context" and once in "Current message". Adapters typically
|
|
641
|
+
// return the latest channel/thread messages, so this overlap is the
|
|
642
|
+
// common case, not the edge case.
|
|
643
|
+
const filteredMessages =
|
|
644
|
+
triggeringMessageId !== undefined
|
|
645
|
+
? result.messages.filter((m) => m.externalMessageId !== triggeringMessageId)
|
|
646
|
+
: result.messages
|
|
647
|
+
if (filteredMessages.length === 0) return
|
|
648
|
+
|
|
649
|
+
const seeded = sliceHeadTail(filteredMessages, head, tail)
|
|
650
|
+
const observed: ObservedInbound[] = []
|
|
651
|
+
for (const item of seeded) {
|
|
652
|
+
if (item.kind === 'message') {
|
|
653
|
+
observed.push({
|
|
654
|
+
text: item.message.text,
|
|
655
|
+
authorId: item.message.authorId,
|
|
656
|
+
authorName: item.message.authorName,
|
|
657
|
+
authorIsBot: item.message.isBot,
|
|
658
|
+
receivedAt: now(),
|
|
659
|
+
ts: item.message.ts,
|
|
660
|
+
})
|
|
661
|
+
} else {
|
|
662
|
+
observed.push({
|
|
663
|
+
text: `[… ${item.elidedCount} earlier messages elided; call channel_history for full thread …]`,
|
|
664
|
+
authorId: '__typeclaw_system__',
|
|
665
|
+
authorName: 'TypeClaw',
|
|
666
|
+
authorIsBot: true,
|
|
667
|
+
receivedAt: now(),
|
|
668
|
+
ts: 0,
|
|
669
|
+
})
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (observed.length === 0) return
|
|
674
|
+
|
|
675
|
+
// Cold-start prefetch is one-shot and may exceed CONTEXT_BUFFER_SIZE — the
|
|
676
|
+
// 20-message cap exists to bound *runtime* observation drift, not the
|
|
677
|
+
// initial seed. Subsequent observe() calls will trim back to the cap as
|
|
678
|
+
// normal. We push into contextBuffer (not promptQueue) because these are
|
|
679
|
+
// background context for the model, not turns it must respond to.
|
|
680
|
+
live.contextBuffer.push(...observed)
|
|
681
|
+
logger.info(`[channels] ${live.keyId}: prefetched ${observed.length} context messages`)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const persistParticipants = async (live: LiveSession): Promise<void> => {
|
|
685
|
+
if (mappings === null) return
|
|
686
|
+
const idx = mappings.findIndex(
|
|
687
|
+
(s) =>
|
|
688
|
+
s.adapter === live.key.adapter &&
|
|
689
|
+
s.workspace === live.key.workspace &&
|
|
690
|
+
s.chat === live.key.chat &&
|
|
691
|
+
(s.thread ?? null) === (live.key.thread ?? null),
|
|
692
|
+
)
|
|
693
|
+
if (idx < 0) return
|
|
694
|
+
const next = mappings.slice()
|
|
695
|
+
next[idx] = { ...next[idx]!, participants: live.participants }
|
|
696
|
+
mappings = next
|
|
697
|
+
await persist()
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const regenerateOrigin = (live: LiveSession): SessionOrigin => buildLiveOrigin(live)
|
|
701
|
+
|
|
702
|
+
const fireTyping = async (live: LiveSession, phase: 'tick' | 'stop'): Promise<void> => {
|
|
703
|
+
const callbacks = typingCallbacks.get(live.key.adapter)
|
|
704
|
+
if (!callbacks || callbacks.size === 0) return
|
|
705
|
+
// Snapshot before iterating: a callback could unregister mid-call.
|
|
706
|
+
const snapshot = Array.from(callbacks)
|
|
707
|
+
const target = {
|
|
708
|
+
adapter: live.key.adapter,
|
|
709
|
+
workspace: live.key.workspace,
|
|
710
|
+
chat: live.key.chat,
|
|
711
|
+
thread: live.key.thread,
|
|
712
|
+
phase,
|
|
713
|
+
}
|
|
714
|
+
await Promise.all(
|
|
715
|
+
snapshot.map((cb) =>
|
|
716
|
+
cb(target).catch((err) => {
|
|
717
|
+
logger.warn(`[channels] typing callback threw for ${live.keyId}: ${describe(err)}`)
|
|
718
|
+
}),
|
|
719
|
+
),
|
|
720
|
+
)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const startTypingHeartbeat = (live: LiveSession): void => {
|
|
724
|
+
if (live.typingTimedOut || live.typingStopPromise) return
|
|
725
|
+
if (live.typingTimer || live.destroyed) return
|
|
726
|
+
live.typingStartedAt = now()
|
|
727
|
+
// Fire immediately so the indicator appears on the very first inbound,
|
|
728
|
+
// not 8 seconds later.
|
|
729
|
+
void fireTyping(live, 'tick')
|
|
730
|
+
live.typingTimer = setInterval(() => {
|
|
731
|
+
if (live.destroyed) {
|
|
732
|
+
void stopTypingHeartbeat(live)
|
|
733
|
+
return
|
|
734
|
+
}
|
|
735
|
+
if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
|
|
736
|
+
logger.warn(`[channels] ${live.keyId}: typing heartbeat timed out after ${MAX_TYPING_HEARTBEAT_MS}ms`)
|
|
737
|
+
live.typingTimedOut = true
|
|
738
|
+
void stopTypingHeartbeat(live)
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
void fireTyping(live, 'tick')
|
|
742
|
+
}, TYPING_HEARTBEAT_MS)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const stopTypingHeartbeat = async (live: LiveSession): Promise<void> => {
|
|
746
|
+
if (!live.typingTimer) {
|
|
747
|
+
await live.typingStopPromise
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
clearInterval(live.typingTimer)
|
|
751
|
+
live.typingTimer = null
|
|
752
|
+
live.typingStartedAt = 0
|
|
753
|
+
// Fire 'stop' phase even when destroyed: adapters need the chance to
|
|
754
|
+
// clear platform-side state (e.g. Slack's 2-min server timeout) on
|
|
755
|
+
// teardown. The FIFO inside the slack adapter ensures this clear lands
|
|
756
|
+
// AFTER any in-flight 'tick' from the heartbeat that just stopped.
|
|
757
|
+
const stopped = fireTyping(live, 'stop').finally(() => {
|
|
758
|
+
if (live.typingStopPromise === stopped) live.typingStopPromise = null
|
|
759
|
+
})
|
|
760
|
+
live.typingStopPromise = stopped
|
|
761
|
+
await stopped
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const fireSessionIdle = async (live: LiveSession): Promise<void> => {
|
|
765
|
+
if (!live.hooks) return
|
|
766
|
+
const work = live.hooks.runSessionIdle({
|
|
767
|
+
sessionId: live.sessionId,
|
|
768
|
+
parentTranscriptPath: live.getTranscriptPath?.(),
|
|
769
|
+
idleMs: 0,
|
|
770
|
+
origin: buildLiveOrigin(live),
|
|
771
|
+
})
|
|
772
|
+
try {
|
|
773
|
+
await raceWithTimeout(work, sessionIdleTimeoutMs, `[channels] ${live.keyId} session.idle`)
|
|
774
|
+
} catch (err) {
|
|
775
|
+
logger.warn(`[channels] session.idle hook threw for ${live.keyId}: ${describe(err)}`)
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const buildLiveOrigin = (live: LiveSession): SessionOrigin => {
|
|
780
|
+
const membership = readMembership(live.key)
|
|
781
|
+
return {
|
|
782
|
+
kind: 'channel',
|
|
783
|
+
adapter: live.key.adapter,
|
|
784
|
+
workspace: live.key.workspace,
|
|
785
|
+
...(live.resolvedNames.workspaceName !== undefined ? { workspaceName: live.resolvedNames.workspaceName } : {}),
|
|
786
|
+
chat: live.key.chat,
|
|
787
|
+
...(live.resolvedNames.chatName !== undefined ? { chatName: live.resolvedNames.chatName } : {}),
|
|
788
|
+
thread: live.key.thread,
|
|
789
|
+
...(live.currentTurnAuthorId !== null ? { lastInboundAuthorId: live.currentTurnAuthorId } : {}),
|
|
790
|
+
participants: live.participants,
|
|
791
|
+
...(membership !== null ? { membership } : {}),
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const fireSessionEnd = async (live: LiveSession): Promise<void> => {
|
|
796
|
+
if (!live.hooks) return
|
|
797
|
+
try {
|
|
798
|
+
await live.hooks.runSessionEnd({ sessionId: live.sessionId })
|
|
799
|
+
} catch (err) {
|
|
800
|
+
logger.warn(`[channels] session.end hook threw for ${live.keyId}: ${describe(err)}`)
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const stopCurrentChannelTurn = async (live: LiveSession): Promise<void> => {
|
|
805
|
+
if (live.debounceTimer) clearTimeout(live.debounceTimer)
|
|
806
|
+
live.debounceTimer = null
|
|
807
|
+
live.firstUnprocessedAt = 0
|
|
808
|
+
live.promptQueue.length = 0
|
|
809
|
+
await stopTypingHeartbeat(live)
|
|
810
|
+
try {
|
|
811
|
+
await live.session.abort()
|
|
812
|
+
logger.info(`[channels] ${live.keyId}: command /stop aborted current turn`)
|
|
813
|
+
} catch (err) {
|
|
814
|
+
logger.warn(`[channels] ${live.keyId}: command /stop abort failed: ${describe(err)}`)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const drain = async (live: LiveSession): Promise<void> => {
|
|
819
|
+
if (live.draining || live.destroyed) return
|
|
820
|
+
live.draining = true
|
|
821
|
+
try {
|
|
822
|
+
while (live.promptQueue.length > 0 && !live.destroyed) {
|
|
823
|
+
live.typingTimedOut = false
|
|
824
|
+
// Heartbeat must run during generation as well as during debounce.
|
|
825
|
+
// Because new inbounds during a turn just push into promptQueue
|
|
826
|
+
// without re-entering route(), the route() call site alone wouldn't
|
|
827
|
+
// keep the indicator alive across multiple drain iterations.
|
|
828
|
+
startTypingHeartbeat(live)
|
|
829
|
+
const batch = live.promptQueue.splice(0, live.promptQueue.length)
|
|
830
|
+
const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
|
|
831
|
+
const text = composeTurnPrompt(observed, batch, { loopGuardActive: live.loopGuardActive })
|
|
832
|
+
|
|
833
|
+
live.currentTurnAuthorId = batch.length > 0 ? batch[batch.length - 1]!.authorId : null
|
|
834
|
+
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
835
|
+
if (batch.length > 0) live.consecutiveSends.clear()
|
|
836
|
+
|
|
837
|
+
// The agent's view of the channel should reflect the current
|
|
838
|
+
// participants + last inbound author. We update the in-memory
|
|
839
|
+
// origin via the session-origin renderer, but the loader was
|
|
840
|
+
// captured at session creation. v0.1 keeps the per-session loader
|
|
841
|
+
// (so origin reflects participants at session-creation time);
|
|
842
|
+
// per-prompt regeneration of system prompts is a v0.2 work.
|
|
843
|
+
void regenerateOrigin
|
|
844
|
+
|
|
845
|
+
// Bracketing logs around the LLM call so a hung prompt() is
|
|
846
|
+
// diagnosable from logs alone (we see prompting without prompted).
|
|
847
|
+
// text length is a proxy for "did we send something at all".
|
|
848
|
+
logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
|
|
849
|
+
const promptStart = now()
|
|
850
|
+
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
851
|
+
try {
|
|
852
|
+
await live.session.prompt(text)
|
|
853
|
+
await validateChannelTurn(live, successfulSendsBeforePrompt)
|
|
854
|
+
live.consecutiveAborts = 0
|
|
855
|
+
logger.info(`[channels] ${live.keyId} prompted elapsed_ms=${now() - promptStart}`)
|
|
856
|
+
} catch (err) {
|
|
857
|
+
logger.warn(`[channels] ${live.keyId}: prompt threw: ${describe(err)}`)
|
|
858
|
+
live.consecutiveSends.clear()
|
|
859
|
+
}
|
|
860
|
+
await fireSessionIdle(live)
|
|
861
|
+
live.lastTurnAuthorIds = new Set(live.currentTurnAuthorIds)
|
|
862
|
+
}
|
|
863
|
+
} finally {
|
|
864
|
+
live.draining = false
|
|
865
|
+
live.currentTurnAuthorId = null
|
|
866
|
+
live.currentTurnAuthorIds = new Set()
|
|
867
|
+
await stopTypingHeartbeat(live)
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const scheduleDebouncedDrain = (live: LiveSession): void => {
|
|
872
|
+
if (live.debounceTimer) clearTimeout(live.debounceTimer)
|
|
873
|
+
const t = now()
|
|
874
|
+
const sinceLast = t - live.lastInboundAt
|
|
875
|
+
const baseWait = sinceLast < HOT_THRESHOLD_MS ? HOT_DEBOUNCE_MS : INITIAL_DEBOUNCE_MS
|
|
876
|
+
if (live.firstUnprocessedAt === 0) live.firstUnprocessedAt = t
|
|
877
|
+
const elapsedSinceFirst = t - live.firstUnprocessedAt
|
|
878
|
+
const wait = Math.max(0, Math.min(baseWait, MAX_DEBOUNCE_MS - elapsedSinceFirst))
|
|
879
|
+
live.lastInboundAt = t
|
|
880
|
+
live.debounceTimer = setTimeout(() => {
|
|
881
|
+
live.debounceTimer = null
|
|
882
|
+
live.firstUnprocessedAt = 0
|
|
883
|
+
void drain(live)
|
|
884
|
+
}, wait)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const route = async (event: InboundMessage): Promise<void> => {
|
|
888
|
+
const adapterConfig = options.configForAdapter(event.adapter)
|
|
889
|
+
if (!adapterConfig) return
|
|
890
|
+
|
|
891
|
+
const key: ChannelKey = {
|
|
892
|
+
adapter: event.adapter,
|
|
893
|
+
workspace: event.workspace,
|
|
894
|
+
chat: event.chat,
|
|
895
|
+
thread: event.thread,
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const parsedCommand = commands.parse(event.text)
|
|
899
|
+
if (parsedCommand !== null) {
|
|
900
|
+
const keyId = channelKeyId(key)
|
|
901
|
+
if (!commands.has(parsedCommand.name)) {
|
|
902
|
+
logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
const existingLive = liveSessions.get(keyId)
|
|
906
|
+
if (!existingLive || existingLive.destroyed) {
|
|
907
|
+
logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
|
|
908
|
+
return
|
|
909
|
+
}
|
|
910
|
+
const commandResult = await commands.execute(event.text, { live: existingLive, event })
|
|
911
|
+
if (commandResult.kind !== 'not-command') return
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const live = await ensureLive(key, event.externalMessageId)
|
|
915
|
+
|
|
916
|
+
const isNewAuthor = !live.participants.some((p) => p.authorId === event.authorId)
|
|
917
|
+
live.participants = updateParticipants(
|
|
918
|
+
live.participants,
|
|
919
|
+
event.authorId,
|
|
920
|
+
event.authorName,
|
|
921
|
+
now(),
|
|
922
|
+
event.authorIsBot,
|
|
923
|
+
)
|
|
924
|
+
void persistParticipants(live)
|
|
925
|
+
|
|
926
|
+
// A previously-unseen author just spoke. The cached membership count
|
|
927
|
+
// (from /members or history-derived) was computed without them, so
|
|
928
|
+
// invalidate and warm in the background. We don't await — the warmup
|
|
929
|
+
// runs alongside this turn's `membershipForEngagement` call so the
|
|
930
|
+
// *next* turn sees fresh data, but the current turn still gets a
|
|
931
|
+
// fast answer (cache miss → cold fetch with timeout, or stale-ok).
|
|
932
|
+
if (isNewAuthor && live.key.workspace !== '@dm') {
|
|
933
|
+
const cache = membershipCaches.get(live.key.adapter)
|
|
934
|
+
if (cache !== undefined) {
|
|
935
|
+
cache.invalidate(live.key)
|
|
936
|
+
void cache.warmUp(live.key).catch((err) => {
|
|
937
|
+
logger.warn(`[channels] membership warmup after new author failed for ${live.keyId}: ${describe(err)}`)
|
|
938
|
+
})
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const membership = await membershipForEngagement(live)
|
|
943
|
+
|
|
944
|
+
const decision: EngagementDecision = decideEngagement({
|
|
945
|
+
message: event,
|
|
946
|
+
config: adapterConfig.engagement,
|
|
947
|
+
key: live.keyId,
|
|
948
|
+
ledger: stickyLedger,
|
|
949
|
+
now: now(),
|
|
950
|
+
participants: live.participants,
|
|
951
|
+
membership,
|
|
952
|
+
selfAliases: computeSelfAliases(),
|
|
953
|
+
botInThread: hasBotParticipated(live),
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
if (decision === 'observe') {
|
|
957
|
+
// Log every observe so an unanswered mention is diagnosable from logs
|
|
958
|
+
// alone instead of "routed but no prompting" silence. The bracketed
|
|
959
|
+
// shape mirrors `prompting batch=` so log scraping can pair them.
|
|
960
|
+
logger.info(`[channels] ${live.keyId} observed id=${event.externalMessageId}`)
|
|
961
|
+
observe(live, event)
|
|
962
|
+
return
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
updateLoopGuard(live, event)
|
|
966
|
+
|
|
967
|
+
enqueue(live, event)
|
|
968
|
+
|
|
969
|
+
// Start showing "typing..." the moment we know we're going to engage,
|
|
970
|
+
// so users see the indicator during the debounce window — not just
|
|
971
|
+
// during LLM generation. drain() will keep it alive across iterations
|
|
972
|
+
// and the finally-block will stop it when the queue empties.
|
|
973
|
+
startTypingHeartbeat(live)
|
|
974
|
+
|
|
975
|
+
if (live.draining) {
|
|
976
|
+
// In-flight turn; let coalesce-on-drain pick it up. Same-author abort
|
|
977
|
+
// is a v0.2 enhancement once we have safe abort semantics through
|
|
978
|
+
// pi-coding-agent for in-flight tool calls.
|
|
979
|
+
return
|
|
980
|
+
}
|
|
981
|
+
scheduleDebouncedDrain(live)
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
|
|
985
|
+
if (!event.authorIsBot) {
|
|
986
|
+
live.recentEngagedPeerBotTurns.length = 0
|
|
987
|
+
live.consecutiveEngagedPeerBotTurns = 0
|
|
988
|
+
live.loopGuardActive = false
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
const t = now()
|
|
992
|
+
live.consecutiveEngagedPeerBotTurns++
|
|
993
|
+
live.recentEngagedPeerBotTurns.push({ authorId: event.authorId, ts: t })
|
|
994
|
+
const cutoff = t - PEER_BOT_TURNS_WINDOW_MS
|
|
995
|
+
while (live.recentEngagedPeerBotTurns.length > 0 && live.recentEngagedPeerBotTurns[0]!.ts < cutoff) {
|
|
996
|
+
live.recentEngagedPeerBotTurns.shift()
|
|
997
|
+
}
|
|
998
|
+
if (
|
|
999
|
+
live.consecutiveEngagedPeerBotTurns >= MAX_CONSECUTIVE_PEER_BOT_TURNS_SINCE_HUMAN ||
|
|
1000
|
+
live.recentEngagedPeerBotTurns.length >= MAX_PEER_BOT_TURNS_IN_WINDOW
|
|
1001
|
+
) {
|
|
1002
|
+
live.loopGuardActive = true
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const hasBotParticipated = (live: LiveSession): boolean => {
|
|
1007
|
+
if (live.successfulChannelSends > 0) return true
|
|
1008
|
+
for (const item of live.contextBuffer) {
|
|
1009
|
+
if (item.authorIsBot) return true
|
|
1010
|
+
}
|
|
1011
|
+
return false
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const observe = (live: LiveSession, event: InboundMessage): void => {
|
|
1015
|
+
live.contextBuffer.push({
|
|
1016
|
+
text: event.text,
|
|
1017
|
+
authorId: event.authorId,
|
|
1018
|
+
authorName: event.authorName,
|
|
1019
|
+
authorIsBot: event.authorIsBot,
|
|
1020
|
+
receivedAt: now(),
|
|
1021
|
+
ts: event.ts,
|
|
1022
|
+
})
|
|
1023
|
+
if (live.contextBuffer.length > CONTEXT_BUFFER_SIZE) {
|
|
1024
|
+
live.contextBuffer.splice(0, live.contextBuffer.length - CONTEXT_BUFFER_SIZE)
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const enqueue = (live: LiveSession, event: InboundMessage): void => {
|
|
1029
|
+
live.promptQueue.push({
|
|
1030
|
+
text: event.text,
|
|
1031
|
+
authorId: event.authorId,
|
|
1032
|
+
authorName: event.authorName,
|
|
1033
|
+
authorIsBot: event.authorIsBot,
|
|
1034
|
+
externalMessageId: event.externalMessageId,
|
|
1035
|
+
isBotMention: event.isBotMention,
|
|
1036
|
+
replyToBotMessageId: event.replyToBotMessageId,
|
|
1037
|
+
isDm: event.isDm,
|
|
1038
|
+
receivedAt: now(),
|
|
1039
|
+
ts: event.ts,
|
|
1040
|
+
})
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const registerOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
|
|
1044
|
+
let set = outboundCallbacks.get(adapter)
|
|
1045
|
+
if (!set) {
|
|
1046
|
+
set = new Set()
|
|
1047
|
+
outboundCallbacks.set(adapter, set)
|
|
1048
|
+
}
|
|
1049
|
+
set.add(cb)
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const unregisterOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
|
|
1053
|
+
outboundCallbacks.get(adapter)?.delete(cb)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const registerTyping = (adapter: ChannelKey['adapter'], cb: TypingCallback): void => {
|
|
1057
|
+
let set = typingCallbacks.get(adapter)
|
|
1058
|
+
if (!set) {
|
|
1059
|
+
set = new Set()
|
|
1060
|
+
typingCallbacks.set(adapter, set)
|
|
1061
|
+
}
|
|
1062
|
+
set.add(cb)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const unregisterTyping = (adapter: ChannelKey['adapter'], cb: TypingCallback): void => {
|
|
1066
|
+
typingCallbacks.get(adapter)?.delete(cb)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const registerChannelNameResolver = (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver): void => {
|
|
1070
|
+
let set = channelNameResolvers.get(adapter)
|
|
1071
|
+
if (!set) {
|
|
1072
|
+
set = new Set()
|
|
1073
|
+
channelNameResolvers.set(adapter, set)
|
|
1074
|
+
}
|
|
1075
|
+
set.add(resolver)
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const unregisterChannelNameResolver = (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver): void => {
|
|
1079
|
+
channelNameResolvers.get(adapter)?.delete(resolver)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const registerMembership = (adapter: ChannelKey['adapter'], resolver: MembershipResolver): void => {
|
|
1083
|
+
let set = membershipResolvers.get(adapter)
|
|
1084
|
+
if (!set) {
|
|
1085
|
+
set = new Set()
|
|
1086
|
+
membershipResolvers.set(adapter, set)
|
|
1087
|
+
}
|
|
1088
|
+
set.add(resolver)
|
|
1089
|
+
if (!membershipCaches.has(adapter)) {
|
|
1090
|
+
membershipCaches.set(
|
|
1091
|
+
adapter,
|
|
1092
|
+
createMembershipCache({ resolver: resolveThroughRegisteredMembership, now, logger }),
|
|
1093
|
+
)
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const unregisterMembership = (adapter: ChannelKey['adapter'], resolver: MembershipResolver): void => {
|
|
1098
|
+
membershipResolvers.get(adapter)?.delete(resolver)
|
|
1099
|
+
if ((membershipResolvers.get(adapter)?.size ?? 0) === 0) {
|
|
1100
|
+
membershipCaches.delete(adapter)
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const registerHistory = (adapter: ChannelKey['adapter'], cb: HistoryCallback): void => {
|
|
1105
|
+
let set = historyCallbacks.get(adapter)
|
|
1106
|
+
if (!set) {
|
|
1107
|
+
set = new Set()
|
|
1108
|
+
historyCallbacks.set(adapter, set)
|
|
1109
|
+
}
|
|
1110
|
+
set.add(cb)
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const unregisterHistory = (adapter: ChannelKey['adapter'], cb: HistoryCallback): void => {
|
|
1114
|
+
historyCallbacks.get(adapter)?.delete(cb)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const fetchHistory = async (adapter: ChannelKey['adapter'], args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
|
|
1118
|
+
const callbacks = historyCallbacks.get(adapter)
|
|
1119
|
+
if (!callbacks || callbacks.size === 0) {
|
|
1120
|
+
return { ok: false, error: 'history-not-supported' }
|
|
1121
|
+
}
|
|
1122
|
+
// Snapshot before iterating, mirroring `send`: a callback that mutates
|
|
1123
|
+
// the set (e.g. unregisters mid-call) must not skip siblings.
|
|
1124
|
+
const snapshot = Array.from(callbacks)
|
|
1125
|
+
let lastError: FetchHistoryResult & { ok: false } = { ok: false, error: 'history-not-supported' }
|
|
1126
|
+
for (const cb of snapshot) {
|
|
1127
|
+
try {
|
|
1128
|
+
const result = await raceWithTimeout(cb(args), fetchHistoryTimeoutMs, `[channels] ${adapter} history fetch`)
|
|
1129
|
+
if (result.ok) return result
|
|
1130
|
+
lastError = result
|
|
1131
|
+
} catch (err) {
|
|
1132
|
+
logger.warn(`[channels] history fetch threw for ${adapter}: ${describe(err)}`)
|
|
1133
|
+
lastError = { ok: false, error: 'history-not-supported' }
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return lastError
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const registerFetchAttachment = (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback): void => {
|
|
1140
|
+
let set = fetchAttachmentCallbacks.get(adapter)
|
|
1141
|
+
if (!set) {
|
|
1142
|
+
set = new Set()
|
|
1143
|
+
fetchAttachmentCallbacks.set(adapter, set)
|
|
1144
|
+
}
|
|
1145
|
+
set.add(cb)
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const unregisterFetchAttachment = (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback): void => {
|
|
1149
|
+
fetchAttachmentCallbacks.get(adapter)?.delete(cb)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const fetchAttachment = async (
|
|
1153
|
+
adapter: ChannelKey['adapter'],
|
|
1154
|
+
args: FetchAttachmentArgs,
|
|
1155
|
+
): Promise<FetchAttachmentResult> => {
|
|
1156
|
+
const callbacks = fetchAttachmentCallbacks.get(adapter)
|
|
1157
|
+
if (!callbacks || callbacks.size === 0) {
|
|
1158
|
+
return { ok: false, error: 'fetch-attachment-not-supported' }
|
|
1159
|
+
}
|
|
1160
|
+
const snapshot = Array.from(callbacks)
|
|
1161
|
+
let lastError: FetchAttachmentResult & { ok: false } = { ok: false, error: 'fetch-attachment-not-supported' }
|
|
1162
|
+
for (const cb of snapshot) {
|
|
1163
|
+
const result = await cb(args)
|
|
1164
|
+
if (result.ok) return result
|
|
1165
|
+
lastError = result
|
|
1166
|
+
}
|
|
1167
|
+
return lastError
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const send = async (msg: OutboundMessage): Promise<SendResult> => {
|
|
1171
|
+
const callbacks = outboundCallbacks.get(msg.adapter)
|
|
1172
|
+
if (!callbacks || callbacks.size === 0) {
|
|
1173
|
+
return { ok: false, error: `no adapter registered for "${msg.adapter}"` }
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Snapshot the callbacks before iterating so a callback that mutates the
|
|
1177
|
+
// set (e.g. unregisters mid-send) does not cause the iterator to skip
|
|
1178
|
+
// siblings or trip into surprising behavior.
|
|
1179
|
+
const snapshot = Array.from(callbacks)
|
|
1180
|
+
let lastError: string | undefined
|
|
1181
|
+
let delivered = false
|
|
1182
|
+
for (const cb of snapshot) {
|
|
1183
|
+
const result = await cb(msg)
|
|
1184
|
+
if (result.ok) {
|
|
1185
|
+
delivered = true
|
|
1186
|
+
break
|
|
1187
|
+
}
|
|
1188
|
+
lastError = result.error
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (!delivered) {
|
|
1192
|
+
return { ok: false, error: lastError ?? 'no callback accepted the outbound' }
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const keyId = channelKeyId({
|
|
1196
|
+
adapter: msg.adapter,
|
|
1197
|
+
workspace: msg.workspace,
|
|
1198
|
+
chat: msg.chat,
|
|
1199
|
+
thread: msg.thread ?? null,
|
|
1200
|
+
})
|
|
1201
|
+
const live = liveSessions.get(keyId)
|
|
1202
|
+
if (live) {
|
|
1203
|
+
live.successfulChannelSends++
|
|
1204
|
+
await stopTypingHeartbeat(live)
|
|
1205
|
+
const adapterConfig = options.configForAdapter(msg.adapter)
|
|
1206
|
+
if (adapterConfig) {
|
|
1207
|
+
const targetIds = Array.from(
|
|
1208
|
+
live.currentTurnAuthorIds.size > 0 ? live.currentTurnAuthorIds : live.lastTurnAuthorIds,
|
|
1209
|
+
)
|
|
1210
|
+
if (targetIds.length > 0) {
|
|
1211
|
+
grantStickyForReplyTargets(stickyLedger, keyId, targetIds, adapterConfig.engagement, now())
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
const sendKey = consecutiveSendKey(msg.chat, msg.thread)
|
|
1215
|
+
live.consecutiveSends.set(sendKey, (live.consecutiveSends.get(sendKey) ?? 0) + 1)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
return { ok: true }
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
|
|
1222
|
+
if (live.successfulChannelSends > successfulSendsBeforePrompt) return
|
|
1223
|
+
|
|
1224
|
+
const assistantText = latestAssistantText(live.session)
|
|
1225
|
+
if (assistantText === null) return
|
|
1226
|
+
|
|
1227
|
+
if (isNoReplySignal(assistantText)) {
|
|
1228
|
+
logger.info(`[channels] ${live.keyId} no_reply`)
|
|
1229
|
+
return
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
logger.warn(
|
|
1233
|
+
`[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
|
|
1234
|
+
)
|
|
1235
|
+
const result = await send({
|
|
1236
|
+
adapter: live.key.adapter,
|
|
1237
|
+
workspace: live.key.workspace,
|
|
1238
|
+
chat: live.key.chat,
|
|
1239
|
+
thread: live.key.thread,
|
|
1240
|
+
text: assistantText,
|
|
1241
|
+
})
|
|
1242
|
+
if (!result.ok) {
|
|
1243
|
+
logger.warn(`[channels] ${live.keyId}: recovery send failed: ${result.error}`)
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const getConsecutiveSendCount = (target: {
|
|
1248
|
+
adapter: ChannelKey['adapter']
|
|
1249
|
+
workspace: string
|
|
1250
|
+
chat: string
|
|
1251
|
+
thread?: string | null
|
|
1252
|
+
}): number => {
|
|
1253
|
+
const keyId = channelKeyId({
|
|
1254
|
+
adapter: target.adapter,
|
|
1255
|
+
workspace: target.workspace,
|
|
1256
|
+
chat: target.chat,
|
|
1257
|
+
thread: target.thread ?? null,
|
|
1258
|
+
})
|
|
1259
|
+
const live = liveSessions.get(keyId)
|
|
1260
|
+
if (!live) return 0
|
|
1261
|
+
return live.consecutiveSends.get(consecutiveSendKey(target.chat, target.thread)) ?? 0
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const tearDownLive = async (live: LiveSession): Promise<void> => {
|
|
1265
|
+
live.destroyed = true
|
|
1266
|
+
if (live.debounceTimer) clearTimeout(live.debounceTimer)
|
|
1267
|
+
live.debounceTimer = null
|
|
1268
|
+
await stopTypingHeartbeat(live)
|
|
1269
|
+
try {
|
|
1270
|
+
await live.session.abort()
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
logger.warn(`[channels] abort failed for ${live.keyId}: ${describe(err)}`)
|
|
1273
|
+
}
|
|
1274
|
+
await fireSessionEnd(live)
|
|
1275
|
+
try {
|
|
1276
|
+
await live.dispose()
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
logger.warn(`[channels] dispose failed for ${live.keyId}: ${describe(err)}`)
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const runIdleGc = async (): Promise<void> => {
|
|
1283
|
+
const t = now()
|
|
1284
|
+
const victims: LiveSession[] = []
|
|
1285
|
+
for (const live of liveSessions.values()) {
|
|
1286
|
+
if (live.destroyed) continue
|
|
1287
|
+
if (live.draining) continue
|
|
1288
|
+
if (live.promptQueue.length > 0) continue
|
|
1289
|
+
if (t - live.lastInboundAt <= SESSION_IDLE_MS) continue
|
|
1290
|
+
victims.push(live)
|
|
1291
|
+
}
|
|
1292
|
+
for (const live of victims) {
|
|
1293
|
+
liveSessions.delete(live.keyId)
|
|
1294
|
+
logger.info(`[channels] ${live.keyId} idle_gc evicting after ${t - live.lastInboundAt}ms idle`)
|
|
1295
|
+
await tearDownLive(live)
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
let gcTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
|
|
1300
|
+
void runIdleGc()
|
|
1301
|
+
}, SESSION_GC_INTERVAL_MS)
|
|
1302
|
+
// Don't keep the Bun process alive just for the GC tick; the host
|
|
1303
|
+
// server's WebSocket listener owns process lifetime.
|
|
1304
|
+
gcTimer.unref?.()
|
|
1305
|
+
|
|
1306
|
+
const stop = async (): Promise<void> => {
|
|
1307
|
+
if (gcTimer) clearInterval(gcTimer)
|
|
1308
|
+
gcTimer = null
|
|
1309
|
+
const all = Array.from(liveSessions.values())
|
|
1310
|
+
liveSessions.clear()
|
|
1311
|
+
for (const live of all) {
|
|
1312
|
+
await tearDownLive(live)
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
return {
|
|
1317
|
+
route,
|
|
1318
|
+
send,
|
|
1319
|
+
getConsecutiveSendCount,
|
|
1320
|
+
registerOutbound,
|
|
1321
|
+
unregisterOutbound,
|
|
1322
|
+
registerTyping,
|
|
1323
|
+
unregisterTyping,
|
|
1324
|
+
registerChannelNameResolver,
|
|
1325
|
+
unregisterChannelNameResolver,
|
|
1326
|
+
registerMembership,
|
|
1327
|
+
unregisterMembership,
|
|
1328
|
+
registerHistory,
|
|
1329
|
+
unregisterHistory,
|
|
1330
|
+
fetchHistory,
|
|
1331
|
+
registerFetchAttachment,
|
|
1332
|
+
unregisterFetchAttachment,
|
|
1333
|
+
fetchAttachment,
|
|
1334
|
+
getSelfAliases: computeSelfAliases,
|
|
1335
|
+
stop,
|
|
1336
|
+
liveCount: () => liveSessions.size,
|
|
1337
|
+
__testing: {
|
|
1338
|
+
flushDebounce: async (key: ChannelKey) => {
|
|
1339
|
+
const live = liveSessions.get(channelKeyId(key))
|
|
1340
|
+
if (!live) return
|
|
1341
|
+
if (live.debounceTimer) {
|
|
1342
|
+
clearTimeout(live.debounceTimer)
|
|
1343
|
+
live.debounceTimer = null
|
|
1344
|
+
}
|
|
1345
|
+
live.firstUnprocessedAt = 0
|
|
1346
|
+
await drain(live)
|
|
1347
|
+
},
|
|
1348
|
+
fireTypingHeartbeat: async (key: ChannelKey, phase: 'tick' | 'stop' = 'tick') => {
|
|
1349
|
+
const live = liveSessions.get(channelKeyId(key))
|
|
1350
|
+
if (!live) return
|
|
1351
|
+
await fireTyping(live, phase)
|
|
1352
|
+
},
|
|
1353
|
+
fireTypingInterval: async (key: ChannelKey) => {
|
|
1354
|
+
const live = liveSessions.get(channelKeyId(key))
|
|
1355
|
+
if (!live || !live.typingTimer) return
|
|
1356
|
+
if (live.destroyed) {
|
|
1357
|
+
await stopTypingHeartbeat(live)
|
|
1358
|
+
return
|
|
1359
|
+
}
|
|
1360
|
+
if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
|
|
1361
|
+
logger.warn(`[channels] ${live.keyId}: typing heartbeat timed out after ${MAX_TYPING_HEARTBEAT_MS}ms`)
|
|
1362
|
+
live.typingTimedOut = true
|
|
1363
|
+
await stopTypingHeartbeat(live)
|
|
1364
|
+
return
|
|
1365
|
+
}
|
|
1366
|
+
await fireTyping(live, 'tick')
|
|
1367
|
+
},
|
|
1368
|
+
isTypingActive: (key: ChannelKey) => {
|
|
1369
|
+
const live = liveSessions.get(channelKeyId(key))
|
|
1370
|
+
return live?.typingTimer !== null && live?.typingTimer !== undefined
|
|
1371
|
+
},
|
|
1372
|
+
runIdleGc,
|
|
1373
|
+
},
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function composeTurnPrompt(
|
|
1378
|
+
observed: readonly ObservedInbound[],
|
|
1379
|
+
batch: readonly QueuedInbound[],
|
|
1380
|
+
state: { loopGuardActive: boolean } = { loopGuardActive: false },
|
|
1381
|
+
): string {
|
|
1382
|
+
const parts: string[] = []
|
|
1383
|
+
// Loop-guard notice lives in the user-turn text (recomposed every drain)
|
|
1384
|
+
// rather than in the system prompt so it does not invalidate the
|
|
1385
|
+
// prompt-prefix cache. The cached prefix covers system + tools + earlier
|
|
1386
|
+
// turns; the current user-turn suffix is non-cacheable by design, so
|
|
1387
|
+
// adding a section here is cache-neutral.
|
|
1388
|
+
//
|
|
1389
|
+
// SYSTEM MESSAGE convention: any runtime-injected block in the user turn
|
|
1390
|
+
// that is NOT from a chat participant must use the
|
|
1391
|
+
// `**[SYSTEM MESSAGE — not from a human]**` framing fenced by horizontal
|
|
1392
|
+
// rules (`---`). This is structurally distinct from the H2 sections used
|
|
1393
|
+
// for actual conversation content (`## Recent context`,
|
|
1394
|
+
// `## Current message`). Without the fencing, models — especially
|
|
1395
|
+
// persona-rich ones like Kimi — read the heading as a human-authored
|
|
1396
|
+
// instruction and reply to it ("알겠습니다, 대화 여기까지 할게요"). The
|
|
1397
|
+
// bracketed marker plus the explicit "Do not acknowledge or reply to this
|
|
1398
|
+
// notice" line is the trust boundary that prevents this. New runtime
|
|
1399
|
+
// notices (rate-limit, schema-mismatch, abort signals, etc.) MUST follow
|
|
1400
|
+
// this same convention so models learn the pattern.
|
|
1401
|
+
if (state.loopGuardActive) {
|
|
1402
|
+
parts.push(
|
|
1403
|
+
'---',
|
|
1404
|
+
'**[SYSTEM MESSAGE — not from a human]**',
|
|
1405
|
+
'',
|
|
1406
|
+
`The TypeClaw runtime detected that peer bots have engaged you ${MAX_CONSECUTIVE_PEER_BOT_TURNS_SINCE_HUMAN}+ times in`,
|
|
1407
|
+
`a row without any human input (or ${MAX_PEER_BOT_TURNS_IN_WINDOW}+ times in the last ${PEER_BOT_TURNS_WINDOW_MS / 1000}s). This message`,
|
|
1408
|
+
'is an automated signal from the channel router, not a message from anyone',
|
|
1409
|
+
'in the chat. **Do not acknowledge or reply to this notice.**',
|
|
1410
|
+
'',
|
|
1411
|
+
'Guidance:',
|
|
1412
|
+
'- If the current message clearly needs a reply, send one and ignore this notice.',
|
|
1413
|
+
'- If continuing would add noise, reply with `NO_REPLY` to stay silent this turn.',
|
|
1414
|
+
'',
|
|
1415
|
+
'This notice clears automatically once a human posts again.',
|
|
1416
|
+
'',
|
|
1417
|
+
'---',
|
|
1418
|
+
'',
|
|
1419
|
+
)
|
|
1420
|
+
}
|
|
1421
|
+
if (observed.length > 0) {
|
|
1422
|
+
parts.push('## Recent context (not addressed to you, for awareness only)')
|
|
1423
|
+
for (const o of observed) {
|
|
1424
|
+
parts.push(formatAuthorLine(o.ts, o.authorId, o.authorName, o.authorIsBot, o.text))
|
|
1425
|
+
}
|
|
1426
|
+
parts.push('')
|
|
1427
|
+
parts.push(batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)')
|
|
1428
|
+
}
|
|
1429
|
+
for (const b of batch) {
|
|
1430
|
+
parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
|
|
1431
|
+
}
|
|
1432
|
+
return parts.join('\n')
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function formatAuthorLine(
|
|
1436
|
+
ts: number,
|
|
1437
|
+
authorId: string,
|
|
1438
|
+
authorName: string,
|
|
1439
|
+
authorIsBot: boolean,
|
|
1440
|
+
text: string,
|
|
1441
|
+
): string {
|
|
1442
|
+
const tag = authorIsBot ? ' [bot]' : ''
|
|
1443
|
+
const stamp = ts > 0 ? `[${new Date(ts).toISOString()}] ` : ''
|
|
1444
|
+
return `${stamp}<@${authorId}> (${authorName})${tag}: ${text}`
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
type Sliced = { kind: 'message'; message: ChannelHistoryMessage } | { kind: 'elision'; elidedCount: number }
|
|
1448
|
+
|
|
1449
|
+
export function sliceHeadTail(messages: readonly ChannelHistoryMessage[], head: number, tail: number): Sliced[] {
|
|
1450
|
+
if (head < 0 || tail < 0) throw new Error(`sliceHeadTail: head and tail must be non-negative (got ${head}, ${tail})`)
|
|
1451
|
+
if (head === 0 && tail === 0) return []
|
|
1452
|
+
if (messages.length <= head + tail) {
|
|
1453
|
+
return messages.map((m) => ({ kind: 'message', message: m }))
|
|
1454
|
+
}
|
|
1455
|
+
const headSlice: Sliced[] = head > 0 ? messages.slice(0, head).map((m) => ({ kind: 'message', message: m })) : []
|
|
1456
|
+
const tailSlice: Sliced[] = tail > 0 ? messages.slice(-tail).map((m) => ({ kind: 'message', message: m })) : []
|
|
1457
|
+
const elidedCount = messages.length - head - tail
|
|
1458
|
+
return [...headSlice, { kind: 'elision', elidedCount }, ...tailSlice]
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function tryOpenSessionManager(
|
|
1462
|
+
agentDir: string,
|
|
1463
|
+
sessionDir: string,
|
|
1464
|
+
existingSessionId: string,
|
|
1465
|
+
existingSessionFile: string | undefined,
|
|
1466
|
+
logger: RouterLogger,
|
|
1467
|
+
): SessionManager {
|
|
1468
|
+
if (existingSessionFile === undefined) {
|
|
1469
|
+
logger.warn(
|
|
1470
|
+
`[channels] session ${existingSessionId} has no sessionFile (v2 mapping not yet migrated); creating new`,
|
|
1471
|
+
)
|
|
1472
|
+
return SessionManager.create(agentDir, sessionDir)
|
|
1473
|
+
}
|
|
1474
|
+
try {
|
|
1475
|
+
const path = `${sessionDir}/${existingSessionFile}`
|
|
1476
|
+
return SessionManager.open(path)
|
|
1477
|
+
} catch (err) {
|
|
1478
|
+
logger.warn(
|
|
1479
|
+
`[channels] could not rehydrate session ${existingSessionId} from ${existingSessionFile}: ${describe(err)}; creating new`,
|
|
1480
|
+
)
|
|
1481
|
+
return SessionManager.create(agentDir, sessionDir)
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function consecutiveSendKey(chat: string, thread: string | null | undefined): string {
|
|
1486
|
+
return `${chat}:${thread ?? ''}`
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function dmMembership(fetchedAt: number): MembershipCount {
|
|
1490
|
+
return { humans: 1, bots: 1, fetchedAt, truncated: false }
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
async function withMembershipTimeout(
|
|
1494
|
+
promise: Promise<MembershipCount | null>,
|
|
1495
|
+
key: ChannelKey,
|
|
1496
|
+
logger: RouterLogger,
|
|
1497
|
+
): Promise<MembershipCount | null> {
|
|
1498
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
1499
|
+
const timeout = new Promise<null>((resolve) => {
|
|
1500
|
+
timer = setTimeout(() => {
|
|
1501
|
+
logger.warn(
|
|
1502
|
+
`[channels] ${channelKeyId(key)}: membership cold fetch timed out after ${MEMBERSHIP_COLD_FETCH_TIMEOUT_MS}ms`,
|
|
1503
|
+
)
|
|
1504
|
+
resolve(null)
|
|
1505
|
+
}, MEMBERSHIP_COLD_FETCH_TIMEOUT_MS)
|
|
1506
|
+
})
|
|
1507
|
+
try {
|
|
1508
|
+
return await Promise.race([promise, timeout])
|
|
1509
|
+
} finally {
|
|
1510
|
+
if (timer !== null) clearTimeout(timer)
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// Throwing variant of the membership timeout pattern: races the work against
|
|
1515
|
+
// a deadline and rejects with a descriptive error on miss. Used wherever a
|
|
1516
|
+
// hung registered callback (Discord/Slack/Telegram REST) would otherwise
|
|
1517
|
+
// leave an awaiting caller stuck forever and there is no graceful-
|
|
1518
|
+
// degradation value the caller could substitute (contrast withMembershipTimeout,
|
|
1519
|
+
// which returns null because engagement can run on a stale membership reading).
|
|
1520
|
+
// The helper owns timer lifetime so callers cannot leak timers on a fast
|
|
1521
|
+
// resolution.
|
|
1522
|
+
async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string): Promise<T> {
|
|
1523
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
1524
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
1525
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
|
|
1526
|
+
})
|
|
1527
|
+
try {
|
|
1528
|
+
return await Promise.race([work, timeout])
|
|
1529
|
+
} finally {
|
|
1530
|
+
if (timer !== null) clearTimeout(timer)
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function latestAssistantText(session: AgentSession): string | null {
|
|
1535
|
+
const entry = session.sessionManager.getLeafEntry()
|
|
1536
|
+
if (entry?.type !== 'message') return null
|
|
1537
|
+
if (entry.message.role !== 'assistant') return null
|
|
1538
|
+
if (entry.message.stopReason !== 'stop') return null
|
|
1539
|
+
return visibleAssistantText(entry.message)
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function visibleAssistantText(message: AssistantMessage): string {
|
|
1543
|
+
return message.content
|
|
1544
|
+
.filter((block) => block.type === 'text')
|
|
1545
|
+
.map((block) => block.text)
|
|
1546
|
+
.join('')
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// Lenient on purpose: distilled / smaller models routinely drift off the
|
|
1550
|
+
// documented `NO_REPLY` form. We additionally accept `(NO_REPLY)` (Claude-style
|
|
1551
|
+
// hedging) and empty visible text (e.g. Kimi-distilled models that emit only a
|
|
1552
|
+
// thinking block and end the turn) — without the empty case we'd recover an
|
|
1553
|
+
// empty string into the chat. The prompt contract still teaches the strict
|
|
1554
|
+
// literal; this just widens what we accept. Shared with channel_send /
|
|
1555
|
+
// channel_reply so all three call sites stay in lockstep.
|
|
1556
|
+
export function isNoReplySignal(text: string): boolean {
|
|
1557
|
+
const trimmed = text.trim()
|
|
1558
|
+
if (trimmed === '') return true
|
|
1559
|
+
if (trimmed === 'NO_REPLY') return true
|
|
1560
|
+
if (trimmed === '(NO_REPLY)') return true
|
|
1561
|
+
return false
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function describe(err: unknown): string {
|
|
1565
|
+
return err instanceof Error ? err.message : String(err)
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Used by tests / external diagnostics.
|
|
1569
|
+
export type { ChannelSessionRecord }
|
|
1570
|
+
export { channelsSessionsPath }
|