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,622 @@
1
+ import {
2
+ KakaoCredentialManager,
3
+ KakaoTalkClient as RealKakaoTalkClient,
4
+ KakaoTalkListener as RealKakaoTalkListener,
5
+ type KakaoChat,
6
+ type KakaoMember,
7
+ type KakaoMessage,
8
+ type KakaoProfile,
9
+ type KakaoSendResult,
10
+ type KakaoTalkListenerEventMap,
11
+ type KakaoTalkPushMessageEvent,
12
+ } from 'agent-messenger/kakaotalk'
13
+
14
+ import type { ChannelRouter } from '@/channels/router'
15
+ import { isAllowed, type ChannelAdapterConfig, type KakaotalkAdapterConfig } from '@/channels/schema'
16
+ import type {
17
+ ChannelHistoryMessage,
18
+ FetchHistoryArgs,
19
+ FetchHistoryResult,
20
+ HistoryCallback,
21
+ OutboundCallback,
22
+ OutboundMessage,
23
+ ResolvedChannelNames,
24
+ SendResult,
25
+ } from '@/channels/types'
26
+
27
+ import { createKakaoAuthorResolver, type KakaoAuthorResolver } from './kakaotalk-author-resolver'
28
+ import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaotalk-channel-resolver'
29
+ import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
30
+
31
+ // Inlined locally because agent-messenger/kakaotalk's index does not
32
+ // re-export KakaoMarkReadResult even though client.markRead returns it
33
+ // (agent-messenger 2.14.1). Upstream re-export fix is independent.
34
+ export interface KakaoMarkReadResult {
35
+ success: boolean
36
+ status_code: number
37
+ chat_id: string
38
+ watermark: string
39
+ }
40
+
41
+ // Structural duck-type of the upstream KakaoTalkClient class. The upstream
42
+ // type is a class with private fields, and TypeScript treats those
43
+ // nominally — test fakes that match the public surface get rejected.
44
+ // Declaring this as an interface lets fakes satisfy it without inheriting
45
+ // private state. The cast on the const below bridges the runtime class
46
+ // onto this interface; the real upstream class satisfies every method.
47
+ export interface KakaoTalkClient {
48
+ login(
49
+ credentials?: { oauthToken: string; userId: string; deviceUuid?: string; deviceType?: 'pc' | 'tablet' },
50
+ accountId?: string,
51
+ ): Promise<this>
52
+ getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]>
53
+ getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]>
54
+ sendMessage(chatId: string, text: string): Promise<KakaoSendResult>
55
+ markRead(chatId: string, logId: string, opts?: { linkId?: string }): Promise<KakaoMarkReadResult>
56
+ getProfile(): Promise<KakaoProfile>
57
+ getMembers(chatId: string): Promise<KakaoMember[]>
58
+ lookupAuthorName(chatId: string, authorId: number): string | null
59
+ close(): void
60
+ }
61
+
62
+ export interface KakaoTalkListener {
63
+ start(): Promise<void>
64
+ stop(): void
65
+ on<K extends keyof KakaoTalkListenerEventMap>(
66
+ event: K,
67
+ listener: (...args: KakaoTalkListenerEventMap[K]) => void,
68
+ ): this
69
+ off<K extends keyof KakaoTalkListenerEventMap>(
70
+ event: K,
71
+ listener: (...args: KakaoTalkListenerEventMap[K]) => void,
72
+ ): this
73
+ }
74
+
75
+ const KakaoTalkClient = RealKakaoTalkClient as unknown as new () => KakaoTalkClient
76
+ const KakaoTalkListener = RealKakaoTalkListener as unknown as new (client: KakaoTalkClient) => KakaoTalkListener
77
+
78
+ export type KakaotalkAdapterLogger = {
79
+ info: (msg: string) => void
80
+ warn: (msg: string) => void
81
+ error: (msg: string) => void
82
+ }
83
+
84
+ const consoleLogger: KakaotalkAdapterLogger = {
85
+ info: (m) => console.log(m),
86
+ warn: (m) => console.warn(m),
87
+ error: (m) => console.error(m),
88
+ }
89
+
90
+ export type KakaotalkAdapterOptions = {
91
+ router: ChannelRouter
92
+ configRef: () => KakaotalkAdapterConfig
93
+ logger?: KakaotalkAdapterLogger
94
+ selfAliasesRef?: () => readonly string[]
95
+ // When set, the adapter loads KakaoTalk credentials from this directory
96
+ // (via KakaoCredentialManager(credentialsDir)) instead of relying on
97
+ // the SDK's AGENT_MESSENGER_CONFIG_DIR env-var fallback. Production
98
+ // wiring in src/channels/manager.ts passes the agent-folder workspace
99
+ // path here so the adapter's credential resolution does NOT depend on
100
+ // process.env state — easier to test, and removes a hidden coupling
101
+ // with whatever set the env var (Dockerfile, CLI shell, etc.).
102
+ credentialsDir?: string
103
+ client?: KakaoTalkClient
104
+ listenerFactory?: (client: KakaoTalkClient) => KakaoTalkListener
105
+ // Test seam for KICKOUT auto-recovery. Production uses Date.now and
106
+ // setTimeout. Tests inject deterministic clocks/schedulers so they can
107
+ // assert the recovery semantics without real-time waits.
108
+ now?: () => number
109
+ scheduleRecovery?: (fn: () => void, delayMs: number) => void
110
+ }
111
+
112
+ // LOCO emits KICKOUT when the same device_uuid logs in elsewhere. Three
113
+ // shapes converge on this one signal:
114
+ // 1. Init handoff — `typeclaw init` left a brief session that the
115
+ // container's re-login kicks. One delayed reconnect resolves it.
116
+ // 2. Ghost session — a previous run's LOCO connection is still
117
+ // half-alive server-side and ping-pongs with our reconnect for
118
+ // ~1-2 minutes until it times out. Old one-shot recovery
119
+ // reconnected once, got kicked again, and died — the bug this
120
+ // state machine exists to fix.
121
+ // 3. Real conflict — another device or process holds the same
122
+ // device_uuid. Our retries can't win this fight; we should give up
123
+ // cleanly so the user notices and intervenes.
124
+ // One signal, three shapes: we always try to recover, but with a
125
+ // strictly bounded budget. Within an episode we allow KICKOUT_RECOVERY_
126
+ // _DELAYS_MS.length retries spaced by the listed delays; an episode is
127
+ // declared successful only after the reconnect stays connected for
128
+ // SUCCESS_MS (bare `connected` is too weak — ghost ping-pong reconnects
129
+ // for seconds before getting kicked again). Past the budget or the
130
+ // MAX_ELAPSED cap we let the session die. After a successful episode
131
+ // the state resets, so a fresh KICKOUT hours later gets a fresh
132
+ // episode rather than being permanently locked out.
133
+ const KICKOUT_RECOVERY_SUCCESS_MS = 60_000
134
+ const KICKOUT_RECOVERY_MAX_ELAPSED_MS = 5 * 60_000
135
+ const KICKOUT_RECOVERY_DELAYS_MS: readonly number[] = [2_000, 10_000, 60_000]
136
+
137
+ export type KakaotalkAdapter = {
138
+ start: () => Promise<void>
139
+ stop: () => Promise<void>
140
+ isConnected: () => boolean
141
+ }
142
+
143
+ export const KAKAO_HISTORY_LIMIT_MAX = 200
144
+
145
+ function formatLabel(name: string | undefined, id: string, prefix = ''): string {
146
+ if (name === undefined || name === '' || name === id) return id
147
+ return `${prefix}${name}(${id})`
148
+ }
149
+
150
+ export function createOutboundCallback(deps: {
151
+ client: Pick<KakaoTalkClient, 'sendMessage'>
152
+ configRef: () => ChannelAdapterConfig
153
+ logger: KakaotalkAdapterLogger
154
+ formatChannelTag: (workspace: string, chat: string) => Promise<string>
155
+ }): OutboundCallback {
156
+ const { client, configRef, logger, formatChannelTag } = deps
157
+ return async (msg: OutboundMessage): Promise<SendResult> => {
158
+ if (msg.adapter !== 'kakaotalk') {
159
+ return { ok: false, error: `unknown adapter: ${msg.adapter}` }
160
+ }
161
+ const config = configRef()
162
+ if (!isAllowed(config.allow, msg.workspace, msg.chat)) {
163
+ logger.warn(`[kakaotalk] outbound denied by allow rules: ${msg.workspace}/${msg.chat}`)
164
+ return { ok: false, error: 'denied by allow rules' }
165
+ }
166
+ const text = msg.text ?? ''
167
+ const attachments = msg.attachments ?? []
168
+ if (attachments.length > 0) {
169
+ // Fail loudly rather than partial-send. The agent contract is "ok=true
170
+ // means the request as a whole succeeded"; sending text while silently
171
+ // dropping the attachments would let the agent confidently report
172
+ // "I sent your file" when the file never arrived.
173
+ logger.error(
174
+ `[kakaotalk] outbound rejected: ${attachments.length} attachment(s) supplied but KakaoTalk is text-only`,
175
+ )
176
+ return {
177
+ ok: false,
178
+ error: 'KakaoTalk does not support attachments; send text without files or use a different channel for files',
179
+ }
180
+ }
181
+ if (text === '') {
182
+ return { ok: false, error: 'message has no text (KakaoTalk does not support attachment-only messages)' }
183
+ }
184
+ const tag = await formatChannelTag(msg.workspace, msg.chat)
185
+ logger.info(`[kakaotalk] outbound ${tag} text_len=${text.length}`)
186
+ try {
187
+ const result = await client.sendMessage(msg.chat, text)
188
+ if (!result.success) {
189
+ logger.error(`[kakaotalk] sendMessage status_code=${result.status_code} ${tag}`)
190
+ return { ok: false, error: `kakaotalk send failed with status ${result.status_code}` }
191
+ }
192
+ logger.info(`[kakaotalk] sent log_id=${result.log_id} ${tag}`)
193
+ return { ok: true }
194
+ } catch (err) {
195
+ const message = describe(err)
196
+ logger.error(`[kakaotalk] sendMessage failed: ${message}`)
197
+ return { ok: false, error: message }
198
+ }
199
+ }
200
+ }
201
+
202
+ export function createKakaoHistoryCallback(deps: {
203
+ client: Pick<KakaoTalkClient, 'getMessages'>
204
+ configRef: () => ChannelAdapterConfig
205
+ logger: KakaotalkAdapterLogger
206
+ channelResolver: Pick<KakaoChannelResolver, 'lookupChat' | 'refresh'>
207
+ authorResolver: Pick<KakaoAuthorResolver, 'resolve'>
208
+ selfUserIdRef: () => string | null
209
+ }): HistoryCallback {
210
+ const { client, configRef, logger, channelResolver, authorResolver, selfUserIdRef } = deps
211
+ return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
212
+ const config = configRef()
213
+ let lookup = channelResolver.lookupChat(args.chat)
214
+ if (lookup === null) {
215
+ await channelResolver.refresh()
216
+ lookup = channelResolver.lookupChat(args.chat)
217
+ }
218
+ // Fallback to the most restrictive bucket (group) when the resolver
219
+ // can't classify after refresh — keeps allow-rule enforcement strict
220
+ // rather than defaulting to a permissive bucket.
221
+ const workspace = lookup?.workspace ?? '@kakao-group'
222
+ if (!isAllowed(config.allow, workspace, args.chat)) {
223
+ return { ok: false, error: 'denied by allow rules' }
224
+ }
225
+ const limit = clampLimit(args.limit, KAKAO_HISTORY_LIMIT_MAX)
226
+ try {
227
+ const messages = await client.getMessages(args.chat, {
228
+ count: limit,
229
+ ...(args.cursor !== undefined && args.cursor !== '' ? { from: args.cursor } : {}),
230
+ })
231
+ const selfId = selfUserIdRef()
232
+ const mapped: ChannelHistoryMessage[] = await Promise.all(
233
+ messages.map(async (m) => {
234
+ const authorId = String(m.author_id)
235
+ const authorName = m.author_name ?? (await authorResolver.resolve(authorId, args.chat)) ?? authorId
236
+ return {
237
+ externalMessageId: m.log_id,
238
+ authorId,
239
+ authorName,
240
+ text: m.message,
241
+ ts: m.sent_at,
242
+ isBot: selfId !== null && authorId === selfId,
243
+ replyToBotMessageId: null,
244
+ }
245
+ }),
246
+ )
247
+ return { ok: true, messages: mapped }
248
+ } catch (err) {
249
+ const message = describe(err)
250
+ logger.warn(`[kakaotalk] history fetch failed: ${message}`)
251
+ return { ok: false, error: message }
252
+ }
253
+ }
254
+ }
255
+
256
+ function clampLimit(requested: number, max: number): number {
257
+ if (!Number.isFinite(requested) || requested <= 0) return max
258
+ return Math.min(Math.floor(requested), max)
259
+ }
260
+
261
+ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): KakaotalkAdapter {
262
+ const logger = options.logger ?? consoleLogger
263
+ const client = options.client ?? new KakaoTalkClient()
264
+ const now = options.now ?? Date.now
265
+ const scheduleRecovery =
266
+ options.scheduleRecovery ??
267
+ ((fn: () => void, delayMs: number): void => {
268
+ setTimeout(fn, delayMs)
269
+ })
270
+ let listener: KakaoTalkListener | null = null
271
+ let selfUserId: string | null = null
272
+ let connected = false
273
+ let started = false
274
+ let lastConnectedAt: number | null = null
275
+ let inflightInbounds = 0
276
+ let stopWaiters: Array<() => void> = []
277
+
278
+ type RecoveryEpisode = {
279
+ startedAt: number
280
+ attemptCount: number
281
+ pendingStabilityCheck: boolean
282
+ }
283
+ let recoveryEpisode: RecoveryEpisode | null = null
284
+
285
+ const resetRecoveryEpisode = (): void => {
286
+ recoveryEpisode = null
287
+ }
288
+
289
+ const channelResolver = createKakaoChannelResolver({ client, logger })
290
+ const authorResolver = createKakaoAuthorResolver({ client, logger })
291
+
292
+ const formatChannelTag = async (workspace: string, chat: string): Promise<string> => {
293
+ const names = await channelResolver
294
+ .resolve({ adapter: 'kakaotalk', workspace, chat, thread: null })
295
+ .catch(() => ({}) as ResolvedChannelNames)
296
+ return `bucket=${workspace} chat=${formatLabel(names.chatName, chat, '#')}`
297
+ }
298
+
299
+ const historyCallback = createKakaoHistoryCallback({
300
+ client,
301
+ configRef: options.configRef,
302
+ logger,
303
+ channelResolver,
304
+ authorResolver,
305
+ selfUserIdRef: () => selfUserId,
306
+ })
307
+
308
+ const outboundCallback = createOutboundCallback({
309
+ client,
310
+ configRef: options.configRef,
311
+ logger,
312
+ formatChannelTag,
313
+ })
314
+
315
+ const handleMessageEvent = async (event: KakaoTalkPushMessageEvent): Promise<void> => {
316
+ inflightInbounds++
317
+ try {
318
+ if (channelResolver.lookupChat(event.chat_id) === null) {
319
+ await channelResolver.refresh()
320
+ }
321
+
322
+ const inboundTag = await formatChannelTag(
323
+ channelResolver.lookupChat(event.chat_id)?.workspace ?? '@kakao-group',
324
+ event.chat_id,
325
+ )
326
+ logger.info(
327
+ `[kakaotalk] inbound log_id=${event.log_id} author=${event.author_id} ${inboundTag} text_len=${event.message.length}`,
328
+ )
329
+
330
+ // Ack the message BEFORE classify/route so the sender's unread "1"
331
+ // (노란숫자) clears even when we drop the message (self-author,
332
+ // not-in-allow, empty text, etc.). The receiver of a kakao adapter is
333
+ // expected to behave like a "read it as soon as it arrives" client —
334
+ // the agent has observed the bytes, so the user should see the read
335
+ // acknowledgement regardless of what we decide to do with the message
336
+ // downstream. Open-chat skip is enforced inside markReadIfSupported.
337
+ markReadIfSupported({ client, event, channelResolver, logger })
338
+
339
+ const verdict = classifyInbound(event, options.configRef(), {
340
+ selfUserId,
341
+ lookupChat: (id) => channelResolver.lookupChat(id),
342
+ ...(options.selfAliasesRef ? { selfAliases: options.selfAliasesRef() } : {}),
343
+ })
344
+ if (verdict.kind === 'drop') {
345
+ const bucket = channelResolver.lookupChat(event.chat_id)?.workspace ?? null
346
+ logger.info(
347
+ `[kakaotalk] dropped log_id=${event.log_id} reason=${verdict.reason}${dropHint(verdict.reason, bucket, event.chat_id)}`,
348
+ )
349
+ return
350
+ }
351
+
352
+ const inlineName = event.author_name
353
+ const resolvedName = inlineName ?? (await authorResolver.resolve(verdict.payload.authorId, verdict.payload.chat))
354
+ const enriched = {
355
+ ...verdict.payload,
356
+ authorName: resolvedName ?? verdict.payload.authorId,
357
+ }
358
+ logger.info(
359
+ `[kakaotalk] routed log_id=${event.log_id} ${inboundTag} mention=${enriched.isBotMention} dm=${enriched.isDm}`,
360
+ )
361
+ await options.router.route(enriched)
362
+ } catch (err) {
363
+ logger.error(`[kakaotalk] handleInbound failed: ${describe(err)}`)
364
+ } finally {
365
+ inflightInbounds--
366
+ if (inflightInbounds === 0 && stopWaiters.length > 0) {
367
+ const waiters = stopWaiters
368
+ stopWaiters = []
369
+ for (const w of waiters) w()
370
+ }
371
+ }
372
+ }
373
+
374
+ return {
375
+ async start(): Promise<void> {
376
+ if (started) return
377
+ started = true
378
+ lastConnectedAt = null
379
+ resetRecoveryEpisode()
380
+ try {
381
+ if (options.credentialsDir !== undefined) {
382
+ // Explicit credential path: read the file ourselves and pass the
383
+ // tokens directly to client.login(). This bypasses the SDK's
384
+ // ensureKakaoAuth() (which reads AGENT_MESSENGER_CONFIG_DIR or
385
+ // ~/.config/agent-messenger), making the adapter independent of
386
+ // process.env state.
387
+ const credManager = new KakaoCredentialManager(options.credentialsDir)
388
+ const account = await credManager.getAccount()
389
+ if (account === null) {
390
+ throw new Error(
391
+ `no KakaoTalk account in ${options.credentialsDir}/kakaotalk-credentials.json (run typeclaw init to authenticate)`,
392
+ )
393
+ }
394
+ await client.login({
395
+ oauthToken: account.oauth_token,
396
+ userId: account.user_id,
397
+ deviceUuid: account.device_uuid,
398
+ deviceType: account.device_type,
399
+ })
400
+ } else {
401
+ // Fall back to the SDK's env-var-driven path. Honors
402
+ // AGENT_MESSENGER_CONFIG_DIR set by the Dockerfile, otherwise
403
+ // ~/.config/agent-messenger.
404
+ await client.login()
405
+ }
406
+ } catch (err) {
407
+ started = false
408
+ logger.error(`[kakaotalk] login failed: ${describe(err)}`)
409
+ throw err
410
+ }
411
+
412
+ try {
413
+ const profile = await client.getProfile()
414
+ selfUserId = profile.user_id
415
+ logger.info(`[kakaotalk] authenticated as ${profile.nickname || profile.user_id} (${profile.user_id})`)
416
+ } catch (err) {
417
+ started = false
418
+ logger.error(`[kakaotalk] getProfile failed: ${describe(err)}`)
419
+ throw err
420
+ }
421
+
422
+ try {
423
+ await channelResolver.refresh()
424
+ } catch (err) {
425
+ logger.warn(`[kakaotalk] initial chat list fetch failed: ${describe(err)}`)
426
+ }
427
+
428
+ listener = options.listenerFactory ? options.listenerFactory(client) : new KakaoTalkListener(client)
429
+ const activeListener = listener
430
+ const scheduleStabilityCheck = (): void => {
431
+ if (recoveryEpisode === null) return
432
+ if (recoveryEpisode.pendingStabilityCheck) return
433
+ recoveryEpisode.pendingStabilityCheck = true
434
+ const expectedConnectedAt = lastConnectedAt
435
+ scheduleRecovery(() => {
436
+ if (recoveryEpisode === null) return
437
+ recoveryEpisode.pendingStabilityCheck = false
438
+ if (!started || listener !== activeListener) return
439
+ if (!connected || lastConnectedAt !== expectedConnectedAt) return
440
+ logger.info(
441
+ `[kakaotalk] KICKOUT recovery episode succeeded after ${recoveryEpisode.attemptCount} attempt(s); session is stable.`,
442
+ )
443
+ resetRecoveryEpisode()
444
+ }, KICKOUT_RECOVERY_SUCCESS_MS)
445
+ }
446
+ listener.on('connected', (info) => {
447
+ connected = true
448
+ lastConnectedAt = now()
449
+ logger.info(`[kakaotalk] connected (user_id=${info.userId})`)
450
+ if (recoveryEpisode !== null) scheduleStabilityCheck()
451
+ })
452
+ listener.on('disconnected', () => {
453
+ connected = false
454
+ logger.warn('[kakaotalk] disconnected; SDK will reconnect with backoff')
455
+ })
456
+ listener.on('error', (err) => {
457
+ logger.error(`[kakaotalk] listener error: ${describe(err)}`)
458
+ if (!isKickoutError(err)) return
459
+ // KICKOUT closes the SDK session and skips scheduleReconnect, so
460
+ // without intervention the adapter goes silent. We must either
461
+ // start/continue a recovery episode or surface the dead state.
462
+ connected = false
463
+ if (!started) return
464
+ const tNow = now()
465
+ if (recoveryEpisode === null) {
466
+ recoveryEpisode = { startedAt: tNow, attemptCount: 0, pendingStabilityCheck: false }
467
+ }
468
+ const episode = recoveryEpisode
469
+ const elapsedInEpisode = tNow - episode.startedAt
470
+ const nextAttemptIndex = episode.attemptCount
471
+ const delayMs = KICKOUT_RECOVERY_DELAYS_MS[nextAttemptIndex]
472
+ if (delayMs === undefined || elapsedInEpisode + delayMs > KICKOUT_RECOVERY_MAX_ELAPSED_MS) {
473
+ const reason =
474
+ delayMs === undefined
475
+ ? `${KICKOUT_RECOVERY_DELAYS_MS.length} attempt(s) exhausted`
476
+ : `${Math.round(KICKOUT_RECOVERY_MAX_ELAPSED_MS / 1000)}s recovery budget exhausted`
477
+ logger.error(
478
+ `[kakaotalk] session is DEAD after KICKOUT — ${reason}. ` +
479
+ 'Likely a real cross-device login is fighting our session. ' +
480
+ 'Stop the other client, then run `typeclaw restart`. ' +
481
+ 'If the conflict persists, re-run `typeclaw init` to mint a new device_uuid.',
482
+ )
483
+ resetRecoveryEpisode()
484
+ return
485
+ }
486
+ episode.attemptCount = nextAttemptIndex + 1
487
+ logger.warn(
488
+ `[kakaotalk] KICKOUT during recovery episode (attempt ${episode.attemptCount}/${KICKOUT_RECOVERY_DELAYS_MS.length}, episode_elapsed=${Math.round(elapsedInEpisode)}ms); reconnecting in ${delayMs}ms.`,
489
+ )
490
+ scheduleRecovery(() => {
491
+ if (!started || listener !== activeListener) return
492
+ if (recoveryEpisode !== episode) return
493
+ activeListener.start().catch((retryErr) => {
494
+ logger.error(
495
+ `[kakaotalk] KICKOUT auto-recovery failed: ${describe(retryErr)}. Run \`typeclaw restart\` to retry.`,
496
+ )
497
+ })
498
+ }, delayMs)
499
+ })
500
+ listener.on('message', (event) => {
501
+ void handleMessageEvent(event)
502
+ })
503
+ listener.on('member_joined', () => {
504
+ void channelResolver.refresh()
505
+ })
506
+ listener.on('member_left', () => {
507
+ void channelResolver.refresh()
508
+ })
509
+
510
+ try {
511
+ await listener.start()
512
+ } catch (err) {
513
+ started = false
514
+ logger.error(`[kakaotalk] listener start failed: ${describe(err)}`)
515
+ throw err
516
+ }
517
+
518
+ // Registration intentionally happens AFTER listener.start() resolves
519
+ // so a start failure cannot leave the router pointing at callbacks
520
+ // belonging to a half-initialized adapter (the listener is closed,
521
+ // but outboundCallback would still send via a dead client). Stop()
522
+ // unregisters in the inverse order.
523
+ options.router.registerOutbound('kakaotalk', outboundCallback)
524
+ options.router.registerChannelNameResolver('kakaotalk', channelResolver.resolve)
525
+ options.router.registerHistory('kakaotalk', historyCallback)
526
+ },
527
+
528
+ async stop(): Promise<void> {
529
+ if (!started) return
530
+ started = false
531
+ options.router.unregisterOutbound('kakaotalk', outboundCallback)
532
+ options.router.unregisterChannelNameResolver('kakaotalk', channelResolver.resolve)
533
+ options.router.unregisterHistory('kakaotalk', historyCallback)
534
+ if (inflightInbounds > 0) {
535
+ await new Promise<void>((resolve) => {
536
+ stopWaiters.push(resolve)
537
+ })
538
+ }
539
+ listener?.stop()
540
+ listener = null
541
+ try {
542
+ client.close()
543
+ } catch {
544
+ // close() throwing on a half-initialized client is benign; the
545
+ // session is gone either way and there's nothing to recover.
546
+ }
547
+ selfUserId = null
548
+ connected = false
549
+ lastConnectedAt = null
550
+ resetRecoveryEpisode()
551
+ },
552
+
553
+ isConnected(): boolean {
554
+ return connected && selfUserId !== null
555
+ },
556
+ }
557
+ }
558
+
559
+ function describe(err: unknown): string {
560
+ return err instanceof Error ? err.message : String(err)
561
+ }
562
+
563
+ function markReadIfSupported(deps: {
564
+ client: Pick<KakaoTalkClient, 'markRead'>
565
+ event: KakaoTalkPushMessageEvent
566
+ channelResolver: Pick<KakaoChannelResolver, 'lookupChat'>
567
+ logger: KakaotalkAdapterLogger
568
+ }): void {
569
+ const { client, event, channelResolver, logger } = deps
570
+ const bucket = channelResolver.lookupChat(event.chat_id)?.workspace
571
+ if (bucket === '@kakao-open') {
572
+ // Open chats require the LOCO `li` (linkId) field on NOTIREAD; without
573
+ // it the server returns a non-success status. The resolver does not
574
+ // surface linkId today, so rather than send a doomed ack we skip and
575
+ // log once. Wiring linkId through the resolver is a follow-up.
576
+ logger.info(
577
+ `[kakaotalk] mark-read skipped chat=${event.chat_id} log=${event.log_id} reason=open_chat_link_id_unsupported`,
578
+ )
579
+ return
580
+ }
581
+ client.markRead(event.chat_id, event.log_id).then(
582
+ (result) => {
583
+ if (!result.success) {
584
+ logger.warn(
585
+ `[kakaotalk] mark-read non-success status_code=${result.status_code} chat=${event.chat_id} log=${event.log_id}`,
586
+ )
587
+ }
588
+ },
589
+ (err) => {
590
+ logger.warn(`[kakaotalk] mark-read failed: ${describe(err)} chat=${event.chat_id} log=${event.log_id}`)
591
+ },
592
+ )
593
+ }
594
+
595
+ function dropHint(
596
+ reason: InboundDropReason,
597
+ bucket: '@kakao-dm' | '@kakao-group' | '@kakao-open' | null,
598
+ chatId: string,
599
+ ): string {
600
+ switch (reason) {
601
+ case 'not_in_allow_list':
602
+ return ` (add ${suggestedAllowPattern(bucket, chatId)} to channels.kakaotalk.allow to admit this chat)`
603
+ case 'unknown_chat':
604
+ return ' (chat not in cache; resolver refresh may be lagging)'
605
+ case 'empty_text':
606
+ case 'pre_connect':
607
+ case 'self_author':
608
+ return ''
609
+ }
610
+ }
611
+
612
+ function suggestedAllowPattern(bucket: '@kakao-dm' | '@kakao-group' | '@kakao-open' | null, chatId: string): string {
613
+ if (bucket === '@kakao-dm') return `"kakao:dm/*" or "kakao:${chatId}"`
614
+ if (bucket === '@kakao-group') return `"kakao:group/*" or "kakao:${chatId}"`
615
+ if (bucket === '@kakao-open') return `"kakao:open/*" or "kakao:${chatId}"`
616
+ return `"kakao:${chatId}"`
617
+ }
618
+
619
+ function isKickoutError(err: unknown): boolean {
620
+ if (!(err instanceof Error)) return false
621
+ return err.message.includes('kicked') || err.message.includes('KICKOUT')
622
+ }
@@ -0,0 +1,80 @@
1
+ const SLACK_API_BASE = 'https://slack.com/api'
2
+ const DEFAULT_TTL_MS = 60 * 60 * 1000
3
+
4
+ export type SlackAuthorResolverOptions = {
5
+ token: string
6
+ now?: () => number
7
+ ttlMs?: number
8
+ }
9
+
10
+ export type SlackAuthorResolver = {
11
+ resolve: (userId: string) => Promise<string>
12
+ }
13
+
14
+ type CacheEntry = { name: string; expiresAt: number }
15
+
16
+ type SlackUsersInfoResponse = {
17
+ ok: boolean
18
+ user?: {
19
+ id?: string
20
+ name?: string
21
+ real_name?: string
22
+ profile?: {
23
+ display_name?: string
24
+ real_name?: string
25
+ }
26
+ }
27
+ }
28
+
29
+ export function createSlackAuthorResolver(options: SlackAuthorResolverOptions): SlackAuthorResolver {
30
+ const now = options.now ?? Date.now
31
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
32
+ const cache = new Map<string, CacheEntry>()
33
+ const inflight = new Map<string, Promise<string>>()
34
+
35
+ const resolve = async (userId: string): Promise<string> => {
36
+ const cached = cache.get(userId)
37
+ if (cached && cached.expiresAt > now()) return cached.name
38
+
39
+ const existing = inflight.get(userId)
40
+ if (existing) return existing
41
+
42
+ const promise = fetchUserName(userId, options.token)
43
+ .then((name) => {
44
+ if (name !== userId) {
45
+ cache.set(userId, { name, expiresAt: now() + ttlMs })
46
+ }
47
+ return name
48
+ })
49
+ .finally(() => {
50
+ inflight.delete(userId)
51
+ })
52
+
53
+ inflight.set(userId, promise)
54
+ return promise
55
+ }
56
+
57
+ return { resolve }
58
+ }
59
+
60
+ async function fetchUserName(userId: string, token: string): Promise<string> {
61
+ try {
62
+ const response = await fetch(`${SLACK_API_BASE}/users.info?user=${encodeURIComponent(userId)}`, {
63
+ headers: { Authorization: `Bearer ${token}` },
64
+ })
65
+ if (!response.ok) return userId
66
+ const body = (await response.json()) as SlackUsersInfoResponse
67
+ if (!body.ok || !body.user) return userId
68
+ return pickDisplayName(body.user) ?? userId
69
+ } catch {
70
+ return userId
71
+ }
72
+ }
73
+
74
+ function pickDisplayName(user: NonNullable<SlackUsersInfoResponse['user']>): string | null {
75
+ const candidates = [user.profile?.display_name, user.profile?.real_name, user.real_name, user.name]
76
+ for (const c of candidates) {
77
+ if (typeof c === 'string' && c !== '') return c
78
+ }
79
+ return null
80
+ }