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.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,227 @@
1
+ import type { ChannelParticipant } from '@/agent/session-origin'
2
+
3
+ import { MEMBERSHIP_FRESHNESS_MS, type MembershipCount } from './membership'
4
+ import type { EngagementConfig } from './schema'
5
+ import type { InboundMessage } from './types'
6
+
7
+ export type EngagementDecision = 'engage' | 'observe'
8
+
9
+ export type StickyCredit = { authorId: string; expiresAt: number }
10
+
11
+ // Per-key sticky credit ledger. Each key (channel tuple) carries at most one
12
+ // active credit per author at a time (subsequent grants overwrite expiry).
13
+ export class StickyLedger {
14
+ private byKey = new Map<string, Map<string, number>>()
15
+
16
+ grant(key: string, authorId: string, expiresAt: number): void {
17
+ let inner = this.byKey.get(key)
18
+ if (!inner) {
19
+ inner = new Map()
20
+ this.byKey.set(key, inner)
21
+ }
22
+ inner.set(authorId, expiresAt)
23
+ }
24
+
25
+ consume(key: string, authorId: string, now: number): boolean {
26
+ const inner = this.byKey.get(key)
27
+ if (!inner) return false
28
+ const expiresAt = inner.get(authorId)
29
+ if (expiresAt === undefined) return false
30
+ inner.delete(authorId)
31
+ if (inner.size === 0) this.byKey.delete(key)
32
+ return expiresAt > now
33
+ }
34
+
35
+ has(key: string, authorId: string, now: number): boolean {
36
+ const expiresAt = this.byKey.get(key)?.get(authorId)
37
+ return expiresAt !== undefined && expiresAt > now
38
+ }
39
+
40
+ clear(key: string): void {
41
+ this.byKey.delete(key)
42
+ }
43
+ }
44
+
45
+ export type EngagementInput = {
46
+ message: InboundMessage
47
+ config: EngagementConfig
48
+ key: string
49
+ ledger: StickyLedger
50
+ now: number
51
+ // Router updates this cache with the current sender BEFORE calling here,
52
+ // so a fresh channel's first human message arrives with length 1. Peer
53
+ // bots DO enter the cache now (they were dropped at adapter level
54
+ // before), so the solo-human fallback below filters them out explicitly
55
+ // — otherwise a 1-human + N-bot channel would silently exit solo mode.
56
+ participants: readonly ChannelParticipant[]
57
+ membership: MembershipCount | null
58
+ // Names the agent answers to in plain text (no @mention syntax). Built
59
+ // by the router as `[basename(agentDir), ...config.alias]` and lowered
60
+ // once. Empty list means alias-based engagement is off — useful for
61
+ // tests and for agents that explicitly want strict-mention behavior.
62
+ // Match semantics: case-insensitive substring of inbound text. This is
63
+ // the operator contract documented in typeclaw-config; if a name is too
64
+ // generic ("bot", "ai") it WILL produce false matches and the operator
65
+ // owns curation.
66
+ selfAliases: readonly string[]
67
+ // True when the bot has previously sent into this exact thread (or
68
+ // channel — the suppressor only checks this when the message is a
69
+ // thread reply, but the field is general). Set by the router from
70
+ // `live.successfulChannelSends > 0` plus any bot-authored prefetched
71
+ // history in `contextBuffer`. Suppresses the `replyToOtherMessageId`
72
+ // gate below: once the bot is participating in a thread, subsequent
73
+ // replies are part of OUR conversation even when `parent_user_id` (the
74
+ // thread root author) is a human. Without this, a thread that started
75
+ // with a human @-mention drops every follow-up reply because Slack's
76
+ // `parent_user_id` always points at the (human) thread root, never the
77
+ // bot's intermediate replies — see incident in PR #58 follow-up.
78
+ botInThread: boolean
79
+ }
80
+
81
+ export function decideEngagement(input: EngagementInput): EngagementDecision {
82
+ const { message, config, key, ledger, now, participants, selfAliases, botInThread } = input
83
+
84
+ if (config.trigger.includes('dm') && message.isDm) return 'engage'
85
+ if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
86
+ if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
87
+
88
+ if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
89
+ return 'engage'
90
+ }
91
+
92
+ // Plain-text name addressing: the user wrote our name (or an alias)
93
+ // somewhere in the message without using <@id> syntax. Engage at the
94
+ // same priority as an explicit mention — operators add aliases
95
+ // precisely because they expect the bot to respond when called by
96
+ // name. Suppression on `mentionsOthers` would defeat the point: the
97
+ // user can address two bots by name in one message ("봉봉아 펭펭아 둘
98
+ // 다 봐") and both should engage. Each bot only knows its own
99
+ // aliases, so cross-bot suppression isn't possible at this layer
100
+ // anyway — the router-side peer-name suppression in the solo-human
101
+ // fallback handles that case (follow-up).
102
+ if (matchesAnyAlias(message.text, selfAliases)) return 'engage'
103
+
104
+ // Solo-human fallback: the strict mention/reply/dm gate keeps the bot
105
+ // quiet in multi-human conversations, but in a 1-human channel that
106
+ // same gate makes the agent silent on messages plainly meant for it.
107
+ // The fallback engages on any human inbound when the channel has at
108
+ // most one human participant, and reverts to strict the moment a second
109
+ // human posts. Peer bots are tracked as participants for context but
110
+ // excluded from the count here, so a 1-human channel stays "solo" even
111
+ // when several bots also speak in it.
112
+ //
113
+ // Two suppressors override the fallback when the message is clearly
114
+ // addressed to someone else:
115
+ // 1. `mentionsOthers` — the message tags at least one other user and
116
+ // none of the mentions resolve to us.
117
+ // 2. `replyToOtherMessageId` — the message is a reply, but the parent
118
+ // was authored by someone other than us (Discord's threaded reply
119
+ // arrow is the canonical case).
120
+ // Both are populated by the adapter classifiers; either one flips the
121
+ // fallback off for that single message without changing channel state.
122
+ // Explicit triggers (DM, mention-of-us, reply-to-us, sticky) above are
123
+ // unaffected — those still engage even when the message also tags a
124
+ // third party.
125
+ //
126
+ // PEER BOTS NEVER QUALIFY for this fallback. It is a courtesy to humans
127
+ // who don't want to type `@bot` in their own DM-like channel; peer bots
128
+ // have no such ergonomic excuse. Letting peer bots ride the fallback
129
+ // produced bot-to-bot conversations in 1-human-N-bot channels (observed:
130
+ // Winky and 돌쇠 introducing themselves to each other after a single
131
+ // "얘들아" from the human, then continuing to address each other for
132
+ // ~6 turns). The router's loop guard only trips after 5 consecutive
133
+ // peer engagements, which is too late to prevent the embarrassment.
134
+ //
135
+ // PHILOSOPHY (do not relitigate): peer bots remain reachable through
136
+ // the SAME triggers as humans (mention/reply/dm/sticky); we do NOT
137
+ // downgrade them to "mention-only". Bot-to-bot conversation is a
138
+ // legitimate first-class use case in this codebase. The fix is to
139
+ // close the unintended fallback, not to firewall bots behind explicit
140
+ // mentions. A future maintainer (human or AI) tempted to add a
141
+ // `peerBotTriggers: 'mention-only'` config — or any equivalent gate
142
+ // that demands explicit `@bot` from peer bots — should not. The user
143
+ // has rejected that design repeatedly. The right knob is `trigger`
144
+ // (which already applies symmetrically to humans and bots) plus this
145
+ // fallback fix.
146
+ if (message.mentionsOthers) return 'observe'
147
+ // The replyToOtherMessageId suppressor exists to keep the bot out of
148
+ // human-to-human side conversations in busy channels. But Slack's
149
+ // `parent_user_id` is the THREAD ROOT author, not the immediate parent
150
+ // author — so a thread the human starts by @-mentioning the bot
151
+ // produces `replyToOtherMessageId` on every follow-up (root author is
152
+ // the human, not us), which would silently drop every reply after the
153
+ // first. Once the bot has actually sent into this thread, subsequent
154
+ // replies are part of OUR conversation regardless of who started it,
155
+ // so the suppressor stops applying. The two-humans-in-a-thread case
156
+ // PR #58 fixed is preserved because the bot never sent into that
157
+ // thread in the first place.
158
+ if (message.replyToOtherMessageId !== null && !botInThread) return 'observe'
159
+
160
+ // Plain-text peer-bot addressing as a fallback suppressor. We've reached
161
+ // here because the message lacks a structural mention/reply/dm AND
162
+ // doesn't contain our own alias. If it DOES contain a known peer bot's
163
+ // observed display name, the solo-human fallback would still engage us
164
+ // — same wrong behavior the alias trigger is meant to fix, just for
165
+ // peers instead of self. Each bot only configures its own aliases, so
166
+ // the only source of peer names is `participants[]` (observed
167
+ // authorName once a peer has spoken at least once in this channel).
168
+ // First-time addressing of a never-seen peer slips through; after that
169
+ // peer's first message it's caught forever.
170
+ if (textTargetsAnyPeerBot(message.text, participants)) return 'observe'
171
+
172
+ const persistedHumans = participants.filter((p) => p.isBot !== true).length
173
+ const effectiveHumans = resolveEffectiveHumans(persistedHumans, input.membership, now)
174
+ if (effectiveHumans <= 1 && !message.authorIsBot) return 'engage'
175
+
176
+ return 'observe'
177
+ }
178
+
179
+ function textTargetsAnyPeerBot(text: string, participants: readonly ChannelParticipant[]): boolean {
180
+ const haystack = text.toLocaleLowerCase()
181
+ for (const p of participants) {
182
+ if (p.isBot !== true) continue
183
+ if (p.authorName === '') continue
184
+ if (haystack.includes(p.authorName.toLocaleLowerCase())) return true
185
+ }
186
+ return false
187
+ }
188
+
189
+ export function resolveEffectiveHumans(
190
+ persistedHumans: number,
191
+ membership: MembershipCount | null,
192
+ now: number,
193
+ ): number {
194
+ if (membership === null) return persistedHumans
195
+ // A fresh complete API read is the only signal that can see lurkers AND
196
+ // prune recent leavers. Letting persisted speakers win here would preserve
197
+ // the exact stale-authorship bug the membership lookup exists to fix.
198
+ const isFresh = now - membership.fetchedAt < MEMBERSHIP_FRESHNESS_MS
199
+ if (!membership.truncated && isFresh) return membership.humans
200
+ // Truncated and stale reads are useful quieting hints, not ground truth.
201
+ // Persisted speakers are bounded to the last 7 days, so `max()` avoids
202
+ // under-counting active humans while the platform count is approximate.
203
+ return Math.max(persistedHumans, membership.humans)
204
+ }
205
+
206
+ export function grantStickyForReplyTargets(
207
+ ledger: StickyLedger,
208
+ key: string,
209
+ authorIds: readonly string[],
210
+ config: EngagementConfig,
211
+ now: number,
212
+ ): void {
213
+ if (config.stickiness === 'off') return
214
+ const window = config.stickiness.perReply.window
215
+ for (const id of authorIds) {
216
+ ledger.grant(key, id, now + window)
217
+ }
218
+ }
219
+
220
+ export function matchesAnyAlias(text: string, lowercasedAliases: readonly string[]): boolean {
221
+ if (lowercasedAliases.length === 0) return false
222
+ const haystack = text.toLocaleLowerCase()
223
+ for (const alias of lowercasedAliases) {
224
+ if (haystack.includes(alias)) return true
225
+ }
226
+ return false
227
+ }
@@ -0,0 +1,21 @@
1
+ export { createChannelManager, type ChannelManager, type ChannelManagerOptions } from './manager'
2
+ export {
3
+ createChannelRouter,
4
+ type ChannelRouter,
5
+ type CreateChannelRouterOptions,
6
+ type CreateSessionForChannel,
7
+ } from './router'
8
+ export { createChannelsReloadable } from './reloadable'
9
+ export {
10
+ channelsSchema,
11
+ isAllowed,
12
+ ADAPTER_IDS,
13
+ STICKY_DEFAULT_WINDOW_MS,
14
+ type AdapterId,
15
+ type AllowRule,
16
+ type ChannelAdapterConfig,
17
+ type ChannelsConfig,
18
+ type EngagementConfig,
19
+ } from './schema'
20
+ export type { ChannelKey, InboundMessage, OutboundCallback, OutboundMessage, SendResult } from './types'
21
+ export { channelKeyId } from './types'
@@ -0,0 +1,292 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/discord-bot'
6
+ import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
7
+ import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
8
+ import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
9
+ import { createChannelRouter, type ChannelRouter, type CreateSessionForChannel } from './router'
10
+ import { ADAPTER_IDS, type AdapterId, type ChannelAdapterConfig, type ChannelsConfig } from './schema'
11
+
12
+ export type ChannelManagerLogger = {
13
+ info: (msg: string) => void
14
+ warn: (msg: string) => void
15
+ error: (msg: string) => void
16
+ }
17
+
18
+ const consoleLogger: ChannelManagerLogger = {
19
+ info: (m) => console.log(m),
20
+ warn: (m) => console.warn(m),
21
+ error: (m) => console.error(m),
22
+ }
23
+
24
+ export type ChannelManagerOptions = {
25
+ agentDir: string
26
+ channelsConfigRef: () => ChannelsConfig
27
+ // Plain-text names the agent answers to in channel engagement (the
28
+ // `alias` field in `typeclaw.json`), forwarded to the router as
29
+ // `configuredAliases`. Read live on every inbound so an `applied`-class
30
+ // reload of `alias` takes effect without a container restart. Omitted
31
+ // means alias-based engagement is off — `basename(agentDir)` is still
32
+ // implicit. This MUST be wired up in production (`src/run/index.ts`)
33
+ // or the configured aliases are silently orphaned: parsed by the
34
+ // schema, never read by anyone. See `manager.test.ts` for the
35
+ // end-to-end engagement assertion that guards this wiring.
36
+ aliasesRef?: () => readonly string[]
37
+ logger?: ChannelManagerLogger
38
+ env?: NodeJS.ProcessEnv
39
+ // Production wiring passes a factory that builds sessions with the full
40
+ // runtime plumbing (channelRouter, stream, plugins, reloadRegistry). When
41
+ // omitted, the router falls back to a hollow factory that creates sessions
42
+ // without a channelRouter — the agent then has no `channel_send` tool and
43
+ // cannot reply, which is fine for tests but a bug in production. See
44
+ // src/run/index.ts where this is wired.
45
+ createSessionForChannel?: CreateSessionForChannel
46
+ // Test seams: let fake adapters replace the real adapter wiring per id.
47
+ createDiscordAdapter?: typeof createDiscordBotAdapter
48
+ createKakaotalkAdapter?: typeof createKakaotalkAdapter
49
+ createSlackAdapter?: typeof createSlackBotAdapter
50
+ createTelegramAdapter?: typeof createTelegramBotAdapter
51
+ }
52
+
53
+ export type ChannelManager = {
54
+ router: ChannelRouter
55
+ start: () => Promise<void>
56
+ stop: () => Promise<void>
57
+ reload: () => Promise<{ started: string[]; stopped: string[]; restartRequired: string[] }>
58
+ }
59
+
60
+ type AnyAdapter = DiscordBotAdapter | KakaotalkAdapter | SlackBotAdapter | TelegramBotAdapter
61
+
62
+ // Credential signature is the comparison key for credential-rotation
63
+ // detection on reload. Discord and Telegram each use a single bot token;
64
+ // Slack needs both a bot token and an app-level token (Socket Mode);
65
+ // KakaoTalk authenticates via a credentials file under
66
+ // AGENT_MESSENGER_CONFIG_DIR (workspace/), so its signature is the file's
67
+ // content hash. The "credential" naming (vs "token") generalizes across the
68
+ // env-var-based adapters and KakaoTalk's file-based credential pathway.
69
+ type AdapterEntry = {
70
+ adapter: AnyAdapter
71
+ credentialSignature: string
72
+ }
73
+
74
+ export function createChannelManager(options: ChannelManagerOptions): ChannelManager {
75
+ const logger = options.logger ?? consoleLogger
76
+ const env = options.env ?? process.env
77
+ const router = createChannelRouter({
78
+ agentDir: options.agentDir,
79
+ configForAdapter: (adapter) => options.channelsConfigRef()[adapter],
80
+ logger,
81
+ ...(options.aliasesRef ? { configuredAliases: options.aliasesRef } : {}),
82
+ ...(options.createSessionForChannel ? { createSessionForChannel: options.createSessionForChannel } : {}),
83
+ })
84
+ const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
85
+ const createKakaotalk = options.createKakaotalkAdapter ?? createKakaotalkAdapter
86
+ const createSlackAdapter = options.createSlackAdapter ?? createSlackBotAdapter
87
+ const createTelegramAdapter = options.createTelegramAdapter ?? createTelegramBotAdapter
88
+
89
+ const live = new Map<AdapterId, AdapterEntry>()
90
+
91
+ const buildCredentialSignature = (name: AdapterId): { signature: string; missing: string[] } => {
92
+ if (name === 'kakaotalk') return buildKakaotalkSignature(options.agentDir, env)
93
+ const requiredEnvs = TOKEN_ENV[name]
94
+ const parts: string[] = []
95
+ const missing: string[] = []
96
+ for (const key of requiredEnvs) {
97
+ const value = env[key]
98
+ if (value === undefined || value.trim() === '') missing.push(key)
99
+ else parts.push(`${key}=${value}`)
100
+ }
101
+ return { signature: parts.join('|'), missing }
102
+ }
103
+
104
+ const buildAdapter = (name: AdapterId, cfg: ChannelAdapterConfig): AnyAdapter | null => {
105
+ if (name === 'discord-bot') {
106
+ const token = env.DISCORD_BOT_TOKEN
107
+ if (token === undefined || token.trim() === '') return null
108
+ return createDiscordAdapter({
109
+ router,
110
+ configRef: () => options.channelsConfigRef()[name] ?? cfg,
111
+ token,
112
+ logger,
113
+ })
114
+ }
115
+ if (name === 'slack-bot') {
116
+ const token = env.SLACK_BOT_TOKEN
117
+ const appToken = env.SLACK_APP_TOKEN
118
+ if (token === undefined || token.trim() === '') return null
119
+ if (appToken === undefined || appToken.trim() === '') return null
120
+ return createSlackAdapter({
121
+ router,
122
+ configRef: () => options.channelsConfigRef()[name] ?? cfg,
123
+ token,
124
+ appToken,
125
+ logger,
126
+ selfAliasesRef: () => router.getSelfAliases(),
127
+ })
128
+ }
129
+ if (name === 'kakaotalk') {
130
+ return createKakaotalk({
131
+ router,
132
+ configRef: () => options.channelsConfigRef()[name] ?? cfg,
133
+ logger,
134
+ selfAliasesRef: () => router.getSelfAliases(),
135
+ credentialsDir: resolveKakaoConfigDir(options.agentDir, env),
136
+ })
137
+ }
138
+ if (name === 'telegram-bot') {
139
+ const token = env.TELEGRAM_BOT_TOKEN
140
+ if (token === undefined || token.trim() === '') return null
141
+ return createTelegramAdapter({
142
+ router,
143
+ configRef: () => options.channelsConfigRef()[name] ?? cfg,
144
+ token,
145
+ logger,
146
+ })
147
+ }
148
+ return null
149
+ }
150
+
151
+ const startAdapter = async (name: AdapterId, cfg: ChannelAdapterConfig): Promise<boolean> => {
152
+ if (cfg.enabled === false) {
153
+ logger.info(`[channels] adapter "${name}" is disabled; skipping`)
154
+ return false
155
+ }
156
+ const { signature, missing } = buildCredentialSignature(name)
157
+ if (missing.length > 0) {
158
+ logger.error(`[channels] adapter "${name}" missing credentials: ${missing.join(', ')}; skipping`)
159
+ return false
160
+ }
161
+ const adapter = buildAdapter(name, cfg)
162
+ if (adapter === null) {
163
+ logger.error(`[channels] adapter "${name}" could not be constructed; skipping`)
164
+ return false
165
+ }
166
+ try {
167
+ await adapter.start()
168
+ live.set(name, { adapter, credentialSignature: signature })
169
+ logger.info(`[channels] adapter "${name}" started`)
170
+ return true
171
+ } catch (err) {
172
+ logger.error(`[channels] adapter "${name}" failed to start: ${describe(err)}`)
173
+ return false
174
+ }
175
+ }
176
+
177
+ const stopAdapter = async (name: AdapterId): Promise<void> => {
178
+ const entry = live.get(name)
179
+ if (!entry) return
180
+ live.delete(name)
181
+ try {
182
+ await entry.adapter.stop()
183
+ logger.info(`[channels] adapter "${name}" stopped`)
184
+ } catch (err) {
185
+ logger.error(`[channels] adapter "${name}" failed to stop: ${describe(err)}`)
186
+ }
187
+ }
188
+
189
+ return {
190
+ router,
191
+
192
+ async start(): Promise<void> {
193
+ const cfg = options.channelsConfigRef()
194
+ for (const name of ADAPTER_IDS) {
195
+ const adapterCfg = cfg[name]
196
+ if (adapterCfg !== undefined) await startAdapter(name, adapterCfg)
197
+ }
198
+ },
199
+
200
+ async stop(): Promise<void> {
201
+ for (const name of Array.from(live.keys())) await stopAdapter(name)
202
+ await router.stop()
203
+ },
204
+
205
+ async reload(): Promise<{ started: string[]; stopped: string[]; restartRequired: string[] }> {
206
+ const cfg = options.channelsConfigRef()
207
+ const started: string[] = []
208
+ const stopped: string[] = []
209
+ const restartRequired: string[] = []
210
+
211
+ for (const name of ADAPTER_IDS) {
212
+ const desired = cfg[name]
213
+ const current = live.get(name)
214
+ if (desired === undefined || desired.enabled === false) {
215
+ if (current) {
216
+ await stopAdapter(name)
217
+ stopped.push(name)
218
+ }
219
+ } else if (!current) {
220
+ const ok = await startAdapter(name, desired)
221
+ if (ok) started.push(name)
222
+ } else {
223
+ const { signature, missing } = buildCredentialSignature(name)
224
+ if (missing.length > 0) {
225
+ // Required credentials disappeared (env vars removed from .env, or
226
+ // KakaoTalk credentials file deleted). Continuing to use the
227
+ // in-memory credentials would silently honor a credential the
228
+ // operator explicitly removed, so stop the adapter instead of
229
+ // waiting for a manual restart.
230
+ logger.warn(
231
+ `[channels] adapter "${name}" missing credentials after reload (${missing.join(', ')}); stopping`,
232
+ )
233
+ await stopAdapter(name)
234
+ stopped.push(name)
235
+ } else if (signature !== current.credentialSignature) {
236
+ const reason = name === 'kakaotalk' ? 'credential rotation' : 'token rotation'
237
+ restartRequired.push(`${name} (${reason})`)
238
+ }
239
+ }
240
+ }
241
+
242
+ return { started, stopped, restartRequired }
243
+ },
244
+ }
245
+ }
246
+
247
+ // Token-based adapters only. KakaoTalk's credentials live in a file under
248
+ // AGENT_MESSENGER_CONFIG_DIR (workspace/.agent-messenger/), not in env, so
249
+ // it goes through buildKakaotalkSignature instead.
250
+ const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk'>, readonly string[]> = {
251
+ 'discord-bot': ['DISCORD_BOT_TOKEN'],
252
+ 'slack-bot': ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
253
+ 'telegram-bot': ['TELEGRAM_BOT_TOKEN'],
254
+ }
255
+
256
+ const KAKAO_DEFAULT_SUBDIR = '.agent-messenger'
257
+ const KAKAO_CREDENTIALS_FILE = 'kakaotalk-credentials.json'
258
+
259
+ function resolveKakaoConfigDir(agentDir: string, env: NodeJS.ProcessEnv): string {
260
+ const override = env.AGENT_MESSENGER_CONFIG_DIR
261
+ if (override !== undefined && override.trim() !== '') return override
262
+ return join(agentDir, 'workspace', KAKAO_DEFAULT_SUBDIR)
263
+ }
264
+
265
+ function resolveKakaoCredentialsPath(agentDir: string, env: NodeJS.ProcessEnv): string {
266
+ return join(resolveKakaoConfigDir(agentDir, env), KAKAO_CREDENTIALS_FILE)
267
+ }
268
+
269
+ function buildKakaotalkSignature(agentDir: string, env: NodeJS.ProcessEnv): { signature: string; missing: string[] } {
270
+ const path = resolveKakaoCredentialsPath(agentDir, env)
271
+ if (!existsSync(path)) {
272
+ return { signature: '', missing: [`kakaotalk credentials file at ${path}`] }
273
+ }
274
+ try {
275
+ // Content hash, not mtime+size: KakaoTalk's credential file is small
276
+ // (a few hundred bytes of JSON) and is rewritten on every OAuth token
277
+ // refresh. Hashing avoids two failure modes mtime+size could miss:
278
+ // (a) a refresh that produces byte-identical content (rare but
279
+ // possible when nothing actually rotated) — we correctly skip;
280
+ // (b) a refresh that lands on the same mtime due to FS resolution
281
+ // (some host filesystems quantize to seconds).
282
+ const buf = readFileSync(path)
283
+ const digest = createHash('sha256').update(buf).digest('hex')
284
+ return { signature: `${path}@sha256:${digest}`, missing: [] }
285
+ } catch (err) {
286
+ return { signature: '', missing: [`kakaotalk credentials file at ${path} (${describe(err)})`] }
287
+ }
288
+ }
289
+
290
+ function describe(err: unknown): string {
291
+ return err instanceof Error ? err.message : String(err)
292
+ }
@@ -0,0 +1,116 @@
1
+ import {
2
+ MEMBERSHIP_CACHE_PERMANENT_TTL_MS,
3
+ MEMBERSHIP_CACHE_TRANSIENT_TTL_MS,
4
+ MEMBERSHIP_CACHE_TTL_MS,
5
+ type MembershipCount,
6
+ type MembershipResolver,
7
+ type MembershipResolverFailure,
8
+ type MembershipResolverResult,
9
+ } from './membership'
10
+ import type { ChannelKey } from './types'
11
+ import { channelKeyId } from './types'
12
+
13
+ export type MembershipCacheRead =
14
+ | { kind: 'hit'; membership: MembershipCount | null }
15
+ | { kind: 'stale'; membership: MembershipCount }
16
+ | { kind: 'miss' }
17
+
18
+ type CacheEntry = {
19
+ result: MembershipResolverResult
20
+ expiresAt: number
21
+ servedStale: boolean
22
+ }
23
+
24
+ export type MembershipCacheLogger = {
25
+ warn: (msg: string) => void
26
+ }
27
+
28
+ export type MembershipCacheOptions = {
29
+ resolver: MembershipResolver
30
+ now?: () => number
31
+ logger?: MembershipCacheLogger
32
+ }
33
+
34
+ export type MembershipCache = {
35
+ read: (key: ChannelKey) => MembershipCacheRead
36
+ get: (key: ChannelKey) => MembershipCount | null
37
+ warmUp: (key: ChannelKey) => Promise<MembershipCount | null>
38
+ invalidate: (key: ChannelKey) => void
39
+ }
40
+
41
+ export function createMembershipCache(options: MembershipCacheOptions): MembershipCache {
42
+ const now = options.now ?? Date.now
43
+ const entries = new Map<string, CacheEntry>()
44
+ const inFlight = new Map<string, Promise<MembershipCount | null>>()
45
+
46
+ const read = (key: ChannelKey): MembershipCacheRead => {
47
+ const entry = entries.get(channelKeyId(key))
48
+ if (entry === undefined) return { kind: 'miss' }
49
+
50
+ if (entry.expiresAt > now()) return { kind: 'hit', membership: toMembership(entry.result) }
51
+ if (isMembershipCount(entry.result) && !entry.servedStale) {
52
+ entry.servedStale = true
53
+ return { kind: 'stale', membership: entry.result }
54
+ }
55
+ return { kind: 'miss' }
56
+ }
57
+
58
+ const warmUp = (key: ChannelKey): Promise<MembershipCount | null> => {
59
+ const keyId = channelKeyId(key)
60
+ const cached = read(key)
61
+ if (cached.kind === 'hit') return Promise.resolve(cached.membership)
62
+ if (cached.kind === 'stale') return Promise.resolve(cached.membership)
63
+
64
+ const existing = inFlight.get(keyId)
65
+ if (existing !== undefined) return existing
66
+
67
+ const promise = resolveAndStore(key, keyId).finally(() => {
68
+ inFlight.delete(keyId)
69
+ })
70
+ inFlight.set(keyId, promise)
71
+ return promise
72
+ }
73
+
74
+ const resolveAndStore = async (key: ChannelKey, keyId: string): Promise<MembershipCount | null> => {
75
+ let result: MembershipResolverResult
76
+ try {
77
+ result = await options.resolver(key)
78
+ } catch (err) {
79
+ options.logger?.warn(`[channels] membership resolver threw for ${keyId}: ${describe(err)}`)
80
+ result = { kind: 'transient' }
81
+ }
82
+ entries.set(keyId, { result, expiresAt: now() + ttlFor(result), servedStale: false })
83
+ return toMembership(result)
84
+ }
85
+
86
+ return {
87
+ read,
88
+ get: (key) => {
89
+ const cached = read(key)
90
+ return cached.kind === 'hit' ? cached.membership : null
91
+ },
92
+ warmUp,
93
+ invalidate: (key) => {
94
+ entries.delete(channelKeyId(key))
95
+ },
96
+ }
97
+ }
98
+
99
+ function ttlFor(result: MembershipResolverResult): number {
100
+ if (isMembershipCount(result)) return MEMBERSHIP_CACHE_TTL_MS
101
+ return result.kind === 'permanent' ? MEMBERSHIP_CACHE_PERMANENT_TTL_MS : MEMBERSHIP_CACHE_TRANSIENT_TTL_MS
102
+ }
103
+
104
+ function toMembership(result: MembershipResolverResult): MembershipCount | null {
105
+ return isMembershipCount(result) ? result : null
106
+ }
107
+
108
+ function isMembershipCount(result: MembershipResolverResult): result is MembershipCount {
109
+ return 'humans' in result
110
+ }
111
+
112
+ function describe(err: unknown): string {
113
+ return err instanceof Error ? err.message : String(err)
114
+ }
115
+
116
+ export type { MembershipResolverFailure }