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,273 @@
1
+ import { z } from 'zod'
2
+
3
+ export const ADAPTER_IDS = ['discord-bot', 'kakaotalk', 'slack-bot', 'telegram-bot'] as const
4
+
5
+ export type AdapterId = (typeof ADAPTER_IDS)[number]
6
+
7
+ const allowRuleSchema = z.string().min(1).refine(isValidAllowRule, {
8
+ message:
9
+ 'allow rule must be one of: *, guild:*, guild:<id>, guild:<id>/<channel>, team:*, team:<id>, team:<id>/<channel>, tg:*, tg:<chat_id>, channel:<id>, dm:*, dm:<id>, im:*, im:<id>, kakao:*, kakao:<chat>, kakao:dm/*, kakao:group/*, kakao:open/*',
10
+ })
11
+
12
+ const engagementTriggerSchema = z.enum(['mention', 'reply', 'dm'])
13
+
14
+ const stickinessSchema = z.union([
15
+ z.literal('off'),
16
+ z.object({
17
+ perReply: z.object({
18
+ window: z
19
+ .number()
20
+ .int()
21
+ .min(1)
22
+ .max(24 * 60 * 60_000),
23
+ }),
24
+ }),
25
+ ])
26
+
27
+ export const STICKY_DEFAULT_WINDOW_MS = 5 * 60 * 1000
28
+
29
+ const engagementSchema = z
30
+ .object({
31
+ trigger: z.array(engagementTriggerSchema).default(['mention', 'reply', 'dm']),
32
+ stickiness: stickinessSchema.default({ perReply: { window: STICKY_DEFAULT_WINDOW_MS } }),
33
+ })
34
+ .default({
35
+ trigger: ['mention', 'reply', 'dm'],
36
+ stickiness: { perReply: { window: STICKY_DEFAULT_WINDOW_MS } },
37
+ })
38
+
39
+ // Cold-start prefetch windows. The router seeds `contextBuffer` once when a
40
+ // brand-new channel session is created (no persisted sessionId for the
41
+ // (workspace, chat, thread) tuple). Set any field to 0 to disable that side
42
+ // of the prefetch. Non-fatal: if the upstream history fetch fails (missing
43
+ // scopes, network error, adapter doesn't expose history), the session still
44
+ // starts and the agent can call `channel_history` on demand.
45
+ //
46
+ // Reload semantics: `channels` is `applied` in FIELD_EFFECTS, but prefetch
47
+ // only fires at session creation, so changes here only affect the *next*
48
+ // cold start; in-flight live sessions are unaffected.
49
+ export const PREFETCH_DEFAULTS = {
50
+ thread: { head: 3, tail: 10 },
51
+ channel: { tail: 10 },
52
+ } as const
53
+
54
+ export function defaultHistoryConfig(): {
55
+ prefetch: { thread: { head: number; tail: number }; channel: { tail: number } }
56
+ } {
57
+ return {
58
+ prefetch: {
59
+ thread: { head: PREFETCH_DEFAULTS.thread.head, tail: PREFETCH_DEFAULTS.thread.tail },
60
+ channel: { tail: PREFETCH_DEFAULTS.channel.tail },
61
+ },
62
+ }
63
+ }
64
+
65
+ const prefetchWindowSchema = z.number().int().min(0).max(200)
66
+
67
+ const historySchema = z
68
+ .object({
69
+ prefetch: z
70
+ .object({
71
+ thread: z
72
+ .object({
73
+ head: prefetchWindowSchema.default(PREFETCH_DEFAULTS.thread.head),
74
+ tail: prefetchWindowSchema.default(PREFETCH_DEFAULTS.thread.tail),
75
+ })
76
+ .default({ head: PREFETCH_DEFAULTS.thread.head, tail: PREFETCH_DEFAULTS.thread.tail }),
77
+ channel: z
78
+ .object({
79
+ tail: prefetchWindowSchema.default(PREFETCH_DEFAULTS.channel.tail),
80
+ })
81
+ .default({ tail: PREFETCH_DEFAULTS.channel.tail }),
82
+ })
83
+ .default({
84
+ thread: { head: PREFETCH_DEFAULTS.thread.head, tail: PREFETCH_DEFAULTS.thread.tail },
85
+ channel: { tail: PREFETCH_DEFAULTS.channel.tail },
86
+ }),
87
+ })
88
+ .default({
89
+ prefetch: {
90
+ thread: { head: PREFETCH_DEFAULTS.thread.head, tail: PREFETCH_DEFAULTS.thread.tail },
91
+ channel: { tail: PREFETCH_DEFAULTS.channel.tail },
92
+ },
93
+ })
94
+
95
+ const adapterSchema = z.object({
96
+ allow: z.array(allowRuleSchema).default([]),
97
+ engagement: engagementSchema,
98
+ history: historySchema,
99
+ enabled: z.boolean().default(true),
100
+ })
101
+
102
+ // KakaoTalk uses the same shape as every other adapter. There used to be an
103
+ // `autoMarkRead` opt-in here; the adapter now fires a LOCO NOTIREAD ack on
104
+ // every inbound MSG event unconditionally (see kakaotalk.ts) so the sender's
105
+ // unread "1" (노란숫자) clears as soon as the agent observes the message.
106
+ // Existing configs with `autoMarkRead: <bool>` continue to parse — Zod's
107
+ // default `.object()` strips unknown keys silently — but the field has no
108
+ // effect. Risk note: auto-acking every received message is a distinct
109
+ // behavioral fingerprint vs a human, so KakaoTalk's abuse detection may
110
+ // flag accounts that ack rapidly and unconditionally. Run typeclaw with the
111
+ // kakaotalk adapter only on dedicated agent accounts you can afford to lose.
112
+ export const channelsSchema = z
113
+ .object({
114
+ 'discord-bot': adapterSchema.optional(),
115
+ kakaotalk: adapterSchema.optional(),
116
+ 'slack-bot': adapterSchema.optional(),
117
+ 'telegram-bot': adapterSchema.optional(),
118
+ })
119
+ .default({})
120
+
121
+ export type AllowRule = string
122
+ export type EngagementConfig = z.infer<typeof engagementSchema>
123
+ export type ChannelAdapterConfig = z.infer<typeof adapterSchema>
124
+ export type KakaotalkAdapterConfig = ChannelAdapterConfig
125
+ export type ChannelsConfig = z.infer<typeof channelsSchema>
126
+
127
+ // Discord IDs are numeric snowflakes; Slack IDs start with a single uppercase
128
+ // letter (T for teams, C/D/G for channels) followed by alphanumerics; Telegram
129
+ // chat IDs are signed integers (negative for groups, `-100…` for supergroups
130
+ // and channels); KakaoTalk chat IDs are LOCO-protocol decimal integers
131
+ // (large enough to need BigInt at the protocol layer, but rendered as plain
132
+ // decimal strings here). All shapes are accepted on every adapter so the
133
+ // allow list stays declarative — the runtime ensures only the right adapter
134
+ // ever sees its own IDs.
135
+ const RULE_PATTERNS = [
136
+ /^\*$/,
137
+ // Discord
138
+ /^guild:\*$/,
139
+ /^guild:[0-9]+$/,
140
+ /^guild:[0-9]+\/[0-9]+$/,
141
+ /^dm:\*$/,
142
+ /^dm:[0-9]+$/,
143
+ // Slack
144
+ /^team:\*$/,
145
+ /^team:[A-Z0-9]+$/,
146
+ /^team:[A-Z0-9]+\/[A-Z0-9]+$/,
147
+ /^im:\*$/,
148
+ /^im:[A-Z0-9]+$/,
149
+ // Telegram (`tg:*` admits all chats; `tg:<chat_id>` scopes to one chat —
150
+ // numeric, may be negative). There is no team/guild concept; every chat is
151
+ // identified by its absolute id.
152
+ /^tg:\*$/,
153
+ /^tg:-?[0-9]+$/,
154
+ // KakaoTalk: a single workspace per logged-in account, so the rules scope
155
+ // by chat-type (1:1 / group / open) rather than by workspace. `kakao:*`
156
+ // admits every chat the account can see; `kakao:dm/*`, `kakao:group/*`,
157
+ // `kakao:open/*` admit one chat-type bucket; `kakao:<chat-id>` admits a
158
+ // single chat. The runtime classifies each chat into a bucket based on
159
+ // KakaoChat.type at chat-resolver time and surfaces the bucket via the
160
+ // workspace coordinate.
161
+ /^kakao:\*$/,
162
+ /^kakao:dm\/\*$/,
163
+ /^kakao:group\/\*$/,
164
+ /^kakao:open\/\*$/,
165
+ /^kakao:[0-9]+$/,
166
+ // Shared (channel ids are unique on both platforms)
167
+ /^channel:[A-Z0-9]+$/,
168
+ /^channel:-?[0-9]+$/,
169
+ ]
170
+
171
+ function isValidAllowRule(rule: string): boolean {
172
+ return RULE_PATTERNS.some((p) => p.test(rule))
173
+ }
174
+
175
+ export function isAllowed(rules: readonly AllowRule[], workspace: string, chat: string): boolean {
176
+ for (const rule of rules) {
177
+ if (matchRule(rule, workspace, chat)) return true
178
+ }
179
+ return false
180
+ }
181
+
182
+ // `*` → every workspace channel + every DM (catch-all)
183
+ // `guild:*` → every Discord guild channel (no DMs)
184
+ // `guild:G` → every channel in guild G
185
+ // `guild:G/C` → channel C in guild G only
186
+ // `team:*` → every Slack team channel (no DMs)
187
+ // `team:T` → every channel in team T
188
+ // `team:T/C` → channel C in team T only
189
+ // `tg:*` → every Telegram chat (DMs, groups, supergroups, channels)
190
+ // `tg:C` → Telegram chat C only (signed numeric chat id)
191
+ // `channel:C` → channel C in any workspace (IDs are globally unique on
192
+ // Discord/Slack and Telegram chat ids are also globally
193
+ // unique numeric values)
194
+ // `dm:*` → every Discord DM
195
+ // `dm:C` → Discord DM channel C only
196
+ // `im:*` → every Slack DM (im channel)
197
+ // `im:D` → Slack DM channel D only
198
+ // `kakao:*` → every KakaoTalk chat the account is in
199
+ // `kakao:dm/*` → every KakaoTalk 1:1 chat
200
+ // `kakao:group/*` → every KakaoTalk group chat
201
+ // `kakao:open/*` → every KakaoTalk open chat
202
+ // `kakao:<id>` → KakaoTalk chat with the given numeric chat_id
203
+ //
204
+ // `guild:`/`dm:`, `team:`/`im:`, `tg:`, and `kakao:` identify which adapter
205
+ // the rule was written for, but the matcher applies any rule that the
206
+ // (workspace, chat) pair satisfies. That keeps the adapter-side coupling at
207
+ // the schema/UX layer (Slack users write `team:`, Discord users write
208
+ // `guild:`, Telegram users write `tg:`, KakaoTalk users write `kakao:`)
209
+ // without bloating the matching logic. Telegram has no workspace concept;
210
+ // the adapter pins workspace to `'telegram'` so `tg:*` only ever admits
211
+ // Telegram chats. KakaoTalk uses `@kakao-dm` / `@kakao-group` / `@kakao-open`
212
+ // as workspace coordinates so the bucket-* rules are pure prefix matches
213
+ // against `workspace`.
214
+ function matchRule(rule: string, workspace: string, chat: string): boolean {
215
+ // KakaoTalk workspaces accept the global `*` catch-all or any `kakao:`
216
+ // rule. Adapter-specific non-kakao rules (`team:*`, `guild:*`, `dm:*`,
217
+ // `im:*`, `tg:*`) never admit kakao workspaces — those are scoped to
218
+ // their own adapter's coordinate space and would be meaningless here.
219
+ // The init wizard still defaults kakaotalk to the narrower `kakao:dm/*`
220
+ // (group chats with personal accounts are sensitive — every member sees
221
+ // every reply), so opting into `*` is an explicit, per-adapter decision
222
+ // made in `channels.kakaotalk.allow`.
223
+ if (KAKAO_WORKSPACES.has(workspace)) {
224
+ if (rule === '*') return true
225
+ if (rule.startsWith('kakao:')) return matchKakaoRule(rule.slice(6), workspace, chat)
226
+ return false
227
+ }
228
+
229
+ if (rule === '*') return true
230
+ if (rule.startsWith('kakao:')) return false
231
+
232
+ if (workspace === '@dm') {
233
+ if (rule === 'dm:*' || rule === 'im:*') return true
234
+ if (rule.startsWith('dm:')) return rule.slice(3) === chat
235
+ if (rule.startsWith('im:')) return rule.slice(3) === chat
236
+ if (rule.startsWith('channel:')) return rule.slice(8) === chat
237
+ return false
238
+ }
239
+
240
+ if (workspace === 'telegram') {
241
+ if (rule === 'tg:*') return true
242
+ if (rule.startsWith('tg:')) return rule.slice(3) === chat
243
+ if (rule.startsWith('channel:')) return rule.slice(8) === chat
244
+ return false
245
+ }
246
+
247
+ if (rule === 'guild:*' || rule === 'team:*') return true
248
+ if (rule.startsWith('channel:')) return rule.slice(8) === chat
249
+ if (rule.startsWith('guild:')) {
250
+ const body = rule.slice(6)
251
+ const slash = body.indexOf('/')
252
+ if (slash === -1) return body === workspace
253
+ return body.slice(0, slash) === workspace && body.slice(slash + 1) === chat
254
+ }
255
+ if (rule.startsWith('team:')) {
256
+ const body = rule.slice(5)
257
+ const slash = body.indexOf('/')
258
+ if (slash === -1) return body === workspace
259
+ return body.slice(0, slash) === workspace && body.slice(slash + 1) === chat
260
+ }
261
+ return false
262
+ }
263
+
264
+ const KAKAO_WORKSPACES = new Set(['@kakao-dm', '@kakao-group', '@kakao-open'])
265
+
266
+ function matchKakaoRule(body: string, workspace: string, chat: string): boolean {
267
+ if (!KAKAO_WORKSPACES.has(workspace)) return false
268
+ if (body === '*') return true
269
+ if (body === 'dm/*') return workspace === '@kakao-dm'
270
+ if (body === 'group/*') return workspace === '@kakao-group'
271
+ if (body === 'open/*') return workspace === '@kakao-open'
272
+ return body === chat
273
+ }
@@ -0,0 +1,160 @@
1
+ import type { AdapterId } from './schema'
2
+
3
+ export type ChannelKey = {
4
+ adapter: AdapterId
5
+ workspace: string
6
+ chat: string
7
+ thread: string | null
8
+ }
9
+
10
+ export type InboundMessage = {
11
+ adapter: AdapterId
12
+ workspace: string
13
+ chat: string
14
+ thread: string | null
15
+ text: string
16
+ externalMessageId: string
17
+ authorId: string
18
+ authorName: string
19
+ // Set true when the inbound is from another bot (NOT this typeclaw
20
+ // instance's own bot identity — the adapter still drops self-authored
21
+ // messages with `reason: 'self_author'`). The router treats peer bots
22
+ // identically to humans for engagement, but uses this flag to drive a
23
+ // bounded loop guard so two or more bots cannot ping-pong forever.
24
+ authorIsBot: boolean
25
+ isBotMention: boolean
26
+ replyToBotMessageId: string | null
27
+ // True when the message contains at least one user mention AND none of
28
+ // those mentions resolve to the bot. Used by the engagement layer to
29
+ // suppress the solo-human fallback: if the human is explicitly tagging
30
+ // someone else, the message almost certainly is not addressed to us.
31
+ // False when the message has no mentions at all (the fallback still
32
+ // applies in that case) or when one of the mentions IS the bot (which
33
+ // is already handled by `isBotMention`). Adapters that cannot reliably
34
+ // enumerate mentions MUST default this to false rather than true.
35
+ mentionsOthers: boolean
36
+ // Set to the parent message id when the inbound is a reply AND the
37
+ // parent was authored by someone other than the bot (or by an unknown
38
+ // author the adapter could not attribute). Mirrors `replyToBotMessageId`
39
+ // but for the inverse case. Used by the engagement layer to suppress
40
+ // the solo-human fallback on Discord-style replies that are clearly
41
+ // directed at another user. Null when the message is not a reply, or
42
+ // when the parent is the bot's own message (already covered by
43
+ // `replyToBotMessageId`). Adapters that cannot determine the parent's
44
+ // author MUST leave this null rather than guessing.
45
+ replyToOtherMessageId: string | null
46
+ isDm: boolean
47
+ // Original platform-side timestamp in milliseconds since epoch. Sourced
48
+ // from Slack's `event.ts` or Discord's `event.timestamp` (via the
49
+ // adapter classifier), NOT the local time the router observed it. Zero
50
+ // means "unknown" — the formatter renders such lines without a
51
+ // timestamp prefix instead of stamping them with the wrong clock.
52
+ ts: number
53
+ }
54
+
55
+ // File on disk that the agent wants to attach to an outbound message. The
56
+ // agent runs inside a container with /agent bind-mounted from the host;
57
+ // `path` should be an absolute path the container can `readFile`. The
58
+ // optional `filename` overrides the basename of `path` when uploading
59
+ // (useful when the on-disk name carries a tempdir suffix the user
60
+ // shouldn't see in the chat). Adapters that cannot upload files MUST
61
+ // fail loudly via `SendResult.ok = false` rather than silently dropping
62
+ // the attachment.
63
+ export type OutboundAttachment = {
64
+ path: string
65
+ filename?: string
66
+ }
67
+
68
+ export type OutboundMessage = {
69
+ adapter: AdapterId
70
+ workspace: string
71
+ chat: string
72
+ thread?: string | null
73
+ // Optional when `attachments` is non-empty (file-only post is allowed).
74
+ // Adapters that always need text (e.g. some webhook backends in the
75
+ // future) must validate this themselves.
76
+ text?: string
77
+ // Each attachment is uploaded once. Order is preserved. For Slack, the
78
+ // first attachment carries `text` as the file's `initial_comment` so
79
+ // both arrive in a single API call; subsequent attachments are uploaded
80
+ // bare. For Discord, attachments are uploaded first (no text) and then
81
+ // `text` is posted as a separate message — Discord's upstream
82
+ // `uploadFile` does not accept a content body or a thread id, see the
83
+ // adapter for the workaround details.
84
+ attachments?: OutboundAttachment[]
85
+ }
86
+
87
+ export type SendResult = { ok: true } | { ok: false; error: string }
88
+
89
+ export type OutboundCallback = (msg: OutboundMessage) => Promise<SendResult>
90
+
91
+ export type TypingTarget = {
92
+ adapter: AdapterId
93
+ workspace: string
94
+ chat: string
95
+ thread?: string | null
96
+ // 'tick' is the heartbeat fired during debouncing/generation; adapters
97
+ // should set the indicator visible. 'stop' is fired exactly once when the
98
+ // router decides the turn is over (drain finally, /stop command, or
99
+ // teardown); adapters should explicitly clear the indicator if their
100
+ // platform doesn't auto-expire it. Without 'stop', a 'tick' that lands
101
+ // after the agent's final reply but before the drain returns will leave
102
+ // the indicator on for Slack's full 2-minute server timeout.
103
+ phase: 'tick' | 'stop'
104
+ }
105
+
106
+ export type TypingCallback = (target: TypingTarget) => Promise<void>
107
+
108
+ export type ResolvedChannelNames = {
109
+ chatName?: string
110
+ workspaceName?: string
111
+ }
112
+
113
+ export type ChannelNameResolver = (key: ChannelKey) => Promise<ResolvedChannelNames>
114
+
115
+ // History entries are intentionally distinct from InboundMessage:
116
+ // `InboundMessage` carries router-classification fields (`isBotMention`,
117
+ // `isDm`) that are turn-delivery concerns, not history concerns. History
118
+ // entries instead need `isBot` so the agent can tell its own past replies
119
+ // from user messages, and a sortable `ts` for chronological rendering.
120
+ export type ChannelHistoryMessage = {
121
+ externalMessageId: string
122
+ authorId: string
123
+ authorName: string
124
+ text: string
125
+ ts: number
126
+ isBot: boolean
127
+ replyToBotMessageId: string | null
128
+ }
129
+
130
+ export type FetchHistoryArgs = {
131
+ chat: string
132
+ thread: string | null
133
+ limit: number
134
+ cursor?: string
135
+ }
136
+
137
+ export type FetchHistoryResult =
138
+ | { ok: true; messages: ChannelHistoryMessage[]; nextCursor?: string }
139
+ | { ok: false; error: string }
140
+
141
+ // Registered per-adapter on the ChannelRouter alongside outbound/typing
142
+ // callbacks. Adapters that cannot fetch history (e.g. webhook-only future
143
+ // adapters) simply do not register one; the router answers
144
+ // 'history-not-supported' for those.
145
+ export type HistoryCallback = (args: FetchHistoryArgs) => Promise<FetchHistoryResult>
146
+
147
+ export type FetchAttachmentArgs = {
148
+ ref: string
149
+ filename?: string
150
+ }
151
+
152
+ export type FetchAttachmentResult =
153
+ | { ok: true; buffer: Buffer; filename: string; mimetype?: string; size: number }
154
+ | { ok: false; error: string }
155
+
156
+ export type FetchAttachmentCallback = (args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
157
+
158
+ export function channelKeyId(key: ChannelKey): string {
159
+ return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
160
+ }