typeclaw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import type { Server as BunServer, ServerWebSocket } from 'bun'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createSessionWithDispose as defaultCreateSessionWithDispose,
|
|
5
|
+
type AgentSession,
|
|
6
|
+
type CreateSessionOptions,
|
|
7
|
+
type CreateSessionResult,
|
|
8
|
+
} from '@/agent'
|
|
9
|
+
import type { SessionOrigin } from '@/agent/session-origin'
|
|
10
|
+
import type { ChannelRouter } from '@/channels/router'
|
|
11
|
+
import type { HookBus } from '@/plugin'
|
|
12
|
+
import type { BrokerWsData, ContainerBroker } from '@/portbroker'
|
|
13
|
+
import type { ReloadAllResult, ReloadRegistry } from '@/reload'
|
|
14
|
+
import type { PluginRuntime, PluginRuntimeState } from '@/run/plugin-runtime'
|
|
15
|
+
import type { SessionFactory } from '@/sessions'
|
|
16
|
+
import type { ClientMessage, PromptDelivery, QueueStateItem, ReloadResultPayload, ServerMessage } from '@/shared'
|
|
17
|
+
import type { Stream, StreamMessage, StreamMessageId, Unsubscribe } from '@/stream'
|
|
18
|
+
|
|
19
|
+
export type ReloadAllFn = () => Promise<ReloadAllResult>
|
|
20
|
+
export type CreateSessionFn = (options?: CreateSessionOptions) => Promise<AgentSession | CreateSessionResult>
|
|
21
|
+
|
|
22
|
+
export type ServerOptions = {
|
|
23
|
+
port: number
|
|
24
|
+
reloadAll?: ReloadAllFn
|
|
25
|
+
reloadRegistry?: ReloadRegistry
|
|
26
|
+
createSession?: CreateSessionFn
|
|
27
|
+
sessionFactory?: SessionFactory
|
|
28
|
+
stream?: Stream
|
|
29
|
+
channelRouter?: ChannelRouter
|
|
30
|
+
agentDir?: string
|
|
31
|
+
pluginRuntime?: PluginRuntime
|
|
32
|
+
containerName?: string
|
|
33
|
+
// Optional in-process portbroker handler. When provided, requests to the
|
|
34
|
+
// /portbroker WS path are routed to it instead of being treated as TUI
|
|
35
|
+
// sessions. Omit to keep TUI-only behavior (used by tests + non-container
|
|
36
|
+
// dev runs).
|
|
37
|
+
containerBroker?: ContainerBroker
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Server = ReturnType<typeof createServer>
|
|
41
|
+
|
|
42
|
+
type TuiWsData = { kind: 'tui'; sessionId: string }
|
|
43
|
+
type WsData = TuiWsData | BrokerWsData
|
|
44
|
+
type Ws = ServerWebSocket<TuiWsData>
|
|
45
|
+
|
|
46
|
+
type QueuedPrompt = {
|
|
47
|
+
streamMessageId: StreamMessageId
|
|
48
|
+
text: string
|
|
49
|
+
delivery: PromptDelivery
|
|
50
|
+
ts: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type SessionState = {
|
|
54
|
+
session: AgentSession
|
|
55
|
+
sessionFileId: string
|
|
56
|
+
origin: SessionOrigin
|
|
57
|
+
sessionManager: { getSessionFile: () => string | undefined } | undefined
|
|
58
|
+
drainQueue: QueuedPrompt[]
|
|
59
|
+
draining: boolean
|
|
60
|
+
unsubBroadcast: Unsubscribe | null
|
|
61
|
+
unsubPrompts: Unsubscribe | null
|
|
62
|
+
// Captured at session open so close-time hooks fire against the same
|
|
63
|
+
// generation that ran session.start. A plugin reload mid-connection does
|
|
64
|
+
// not re-target this session's lifecycle hooks.
|
|
65
|
+
runtimeSnapshot: PluginRuntimeState | null
|
|
66
|
+
dispose: () => Promise<void>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function send(ws: Ws, msg: ServerMessage) {
|
|
70
|
+
ws.send(JSON.stringify(msg))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createServer({
|
|
74
|
+
port,
|
|
75
|
+
reloadAll,
|
|
76
|
+
reloadRegistry,
|
|
77
|
+
createSession = defaultCreateSessionWithDispose,
|
|
78
|
+
sessionFactory,
|
|
79
|
+
stream,
|
|
80
|
+
channelRouter,
|
|
81
|
+
agentDir,
|
|
82
|
+
pluginRuntime,
|
|
83
|
+
containerName,
|
|
84
|
+
containerBroker,
|
|
85
|
+
}: ServerOptions) {
|
|
86
|
+
const sessionStates = new WeakMap<Ws, SessionState>()
|
|
87
|
+
|
|
88
|
+
function start(): BunServer<WsData> {
|
|
89
|
+
const bunServer = Bun.serve<WsData>({
|
|
90
|
+
port,
|
|
91
|
+
fetch(req, server) {
|
|
92
|
+
const url = new URL(req.url)
|
|
93
|
+
if (url.pathname === '/portbroker') {
|
|
94
|
+
if (!containerBroker) return new Response('portbroker disabled', { status: 404 })
|
|
95
|
+
const data: BrokerWsData = { kind: 'portbroker', authed: false }
|
|
96
|
+
if (server.upgrade(req, { data })) return
|
|
97
|
+
return new Response('upgrade failed', { status: 400 })
|
|
98
|
+
}
|
|
99
|
+
const sessionId = crypto.randomUUID()
|
|
100
|
+
const data: TuiWsData = { kind: 'tui', sessionId }
|
|
101
|
+
if (server.upgrade(req, { data })) return
|
|
102
|
+
return new Response('typeclaw agent', { status: 200 })
|
|
103
|
+
},
|
|
104
|
+
websocket: {
|
|
105
|
+
async open(rawWs) {
|
|
106
|
+
if (rawWs.data.kind === 'portbroker') {
|
|
107
|
+
containerBroker?.open(rawWs as ServerWebSocket<BrokerWsData>)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const ws = rawWs as Ws
|
|
111
|
+
const sessionManager = sessionFactory?.createPersisted()
|
|
112
|
+
const sessionFileId = sessionManager?.getSessionId() ?? ws.data.sessionId
|
|
113
|
+
// Snapshot the runtime once so the entire session lifecycle for this
|
|
114
|
+
// ws connection sees one consistent generation of registry+hooks. A
|
|
115
|
+
// reload landing mid-connection swaps the live pointer; this session
|
|
116
|
+
// keeps using the snapshot it was created with until close.
|
|
117
|
+
const runtimeSnapshot = pluginRuntime?.get()
|
|
118
|
+
const pluginsWiring =
|
|
119
|
+
runtimeSnapshot !== undefined && agentDir !== undefined
|
|
120
|
+
? {
|
|
121
|
+
registry: runtimeSnapshot.registry,
|
|
122
|
+
hooks: runtimeSnapshot.hooks,
|
|
123
|
+
sessionId: sessionFileId,
|
|
124
|
+
agentDir,
|
|
125
|
+
}
|
|
126
|
+
: undefined
|
|
127
|
+
const origin: SessionOrigin = { kind: 'tui', sessionId: sessionFileId }
|
|
128
|
+
const result = await createSession({
|
|
129
|
+
reloadRegistry,
|
|
130
|
+
sessionManager,
|
|
131
|
+
origin,
|
|
132
|
+
...(stream ? { stream } : {}),
|
|
133
|
+
...(channelRouter ? { channelRouter } : {}),
|
|
134
|
+
...(pluginsWiring ? { plugins: pluginsWiring } : {}),
|
|
135
|
+
...(containerName !== undefined ? { containerName } : {}),
|
|
136
|
+
})
|
|
137
|
+
const session = 'session' in result ? result.session : result
|
|
138
|
+
const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
|
|
139
|
+
|
|
140
|
+
const state: SessionState = {
|
|
141
|
+
session,
|
|
142
|
+
sessionFileId,
|
|
143
|
+
origin,
|
|
144
|
+
sessionManager,
|
|
145
|
+
drainQueue: [],
|
|
146
|
+
draining: false,
|
|
147
|
+
unsubBroadcast: null,
|
|
148
|
+
unsubPrompts: null,
|
|
149
|
+
runtimeSnapshot: runtimeSnapshot ?? null,
|
|
150
|
+
dispose,
|
|
151
|
+
}
|
|
152
|
+
sessionStates.set(ws, state)
|
|
153
|
+
|
|
154
|
+
if (runtimeSnapshot !== undefined && agentDir !== undefined) {
|
|
155
|
+
await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
forwardSessionEvents(ws, session)
|
|
159
|
+
|
|
160
|
+
if (stream) {
|
|
161
|
+
state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
|
|
162
|
+
enqueuePrompt(ws, state, msg),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
166
|
+
const payload: ServerMessage = {
|
|
167
|
+
type: 'notification',
|
|
168
|
+
payload: msg.payload,
|
|
169
|
+
...(msg.replyTo !== undefined ? { replyTo: msg.replyTo } : {}),
|
|
170
|
+
...(msg.meta !== undefined ? { meta: msg.meta } : {}),
|
|
171
|
+
}
|
|
172
|
+
send(ws, payload)
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
send(ws, { type: 'connected', sessionId: sessionFileId })
|
|
177
|
+
console.log(`session ${sessionFileId}: open`)
|
|
178
|
+
},
|
|
179
|
+
async message(rawWs, raw) {
|
|
180
|
+
if (rawWs.data.kind === 'portbroker') {
|
|
181
|
+
await containerBroker?.message(rawWs as ServerWebSocket<BrokerWsData>, raw as string | Buffer)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const ws = rawWs as Ws
|
|
185
|
+
const msg = JSON.parse(String(raw)) as ClientMessage
|
|
186
|
+
const state = sessionStates.get(ws)
|
|
187
|
+
|
|
188
|
+
if (msg.type === 'reload') {
|
|
189
|
+
await handleReload(ws, reloadAll, reloadRegistry, msg.scope)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (msg.type === 'abort') {
|
|
194
|
+
if (!state) return
|
|
195
|
+
await state.session.abort()
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (msg.type === 'queue_cancel') {
|
|
200
|
+
if (!state) return
|
|
201
|
+
const before = state.drainQueue.length
|
|
202
|
+
state.drainQueue = state.drainQueue.filter((q) => q.streamMessageId !== msg.messageId)
|
|
203
|
+
if (state.drainQueue.length !== before) pushQueueState(ws, state)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (msg.type === 'prompt') {
|
|
208
|
+
if (!state) return
|
|
209
|
+
if (stream) {
|
|
210
|
+
stream.publish({
|
|
211
|
+
target: { kind: 'session', sessionId: state.sessionFileId },
|
|
212
|
+
payload: { kind: 'prompt', text: msg.text, delivery: msg.delivery ?? 'queue' },
|
|
213
|
+
meta: { source: 'tui' },
|
|
214
|
+
})
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
send(ws, { type: 'prompt_started', messageId: `local-${crypto.randomUUID()}`, text: msg.text })
|
|
218
|
+
try {
|
|
219
|
+
await state.session.prompt(msg.text)
|
|
220
|
+
send(ws, { type: 'done' })
|
|
221
|
+
} catch (err) {
|
|
222
|
+
send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
|
|
223
|
+
}
|
|
224
|
+
const fallbackHooks = state.runtimeSnapshot?.hooks
|
|
225
|
+
if (fallbackHooks !== undefined) {
|
|
226
|
+
await fallbackHooks.runSessionIdle({
|
|
227
|
+
sessionId: state.sessionFileId,
|
|
228
|
+
parentTranscriptPath: state.sessionManager?.getSessionFile(),
|
|
229
|
+
idleMs: 0,
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
async close(rawWs) {
|
|
236
|
+
if (rawWs.data.kind === 'portbroker') {
|
|
237
|
+
containerBroker?.close(rawWs as ServerWebSocket<BrokerWsData>)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
const ws = rawWs as Ws
|
|
241
|
+
const state = sessionStates.get(ws)
|
|
242
|
+
state?.unsubBroadcast?.()
|
|
243
|
+
state?.unsubPrompts?.()
|
|
244
|
+
try {
|
|
245
|
+
if (state && state.runtimeSnapshot !== null) {
|
|
246
|
+
await state.runtimeSnapshot.hooks.runSessionEnd({ sessionId: state.sessionFileId })
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
if (state) {
|
|
250
|
+
state.session.dispose()
|
|
251
|
+
await state.dispose()
|
|
252
|
+
}
|
|
253
|
+
sessionStates.delete(ws)
|
|
254
|
+
console.log(`session ${state?.sessionFileId ?? ws.data.sessionId}: close`)
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
console.log(`typeclaw agent listening on ws://localhost:${bunServer.port}`)
|
|
261
|
+
return bunServer
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { start }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function forwardSessionEvents(ws: Ws, session: AgentSession): void {
|
|
268
|
+
const toolStartedAt = new Map<string, number>()
|
|
269
|
+
|
|
270
|
+
session.subscribe((event) => {
|
|
271
|
+
switch (event.type) {
|
|
272
|
+
case 'message_update':
|
|
273
|
+
if (event.assistantMessageEvent.type === 'text_delta') {
|
|
274
|
+
send(ws, { type: 'text_delta', delta: event.assistantMessageEvent.delta })
|
|
275
|
+
}
|
|
276
|
+
break
|
|
277
|
+
case 'message_end':
|
|
278
|
+
// pi-coding-agent encodes upstream LLM failures (billing, rate limit,
|
|
279
|
+
// network, malformed response, etc.) in the assistant message itself
|
|
280
|
+
// rather than throwing — `stopReason: 'error'` with a populated
|
|
281
|
+
// `errorMessage`. Without this branch the user sees an empty turn
|
|
282
|
+
// because no text deltas were ever emitted, which looks like a freeze.
|
|
283
|
+
// The server's existing try/catch around `session.prompt()` only
|
|
284
|
+
// catches throws, so it never sees these.
|
|
285
|
+
forwardAssistantError(ws, event.message)
|
|
286
|
+
break
|
|
287
|
+
case 'tool_execution_start':
|
|
288
|
+
toolStartedAt.set(event.toolCallId, Date.now())
|
|
289
|
+
send(ws, {
|
|
290
|
+
type: 'tool_start',
|
|
291
|
+
toolCallId: event.toolCallId,
|
|
292
|
+
name: event.toolName,
|
|
293
|
+
args: event.args,
|
|
294
|
+
})
|
|
295
|
+
break
|
|
296
|
+
case 'tool_execution_end': {
|
|
297
|
+
const startedAt = toolStartedAt.get(event.toolCallId)
|
|
298
|
+
toolStartedAt.delete(event.toolCallId)
|
|
299
|
+
const durationMs = startedAt === undefined ? 0 : Date.now() - startedAt
|
|
300
|
+
send(ws, {
|
|
301
|
+
type: 'tool_end',
|
|
302
|
+
toolCallId: event.toolCallId,
|
|
303
|
+
name: event.toolName,
|
|
304
|
+
error: event.isError,
|
|
305
|
+
result: event.result,
|
|
306
|
+
durationMs,
|
|
307
|
+
})
|
|
308
|
+
break
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function forwardAssistantError(ws: Ws, message: unknown): void {
|
|
315
|
+
if (typeof message !== 'object' || message === null) return
|
|
316
|
+
const m = message as { role?: string; stopReason?: string; errorMessage?: string }
|
|
317
|
+
if (m.role !== 'assistant') return
|
|
318
|
+
if (m.stopReason !== 'error' && m.stopReason !== 'aborted') return
|
|
319
|
+
// 'aborted' is fired when the user hits Escape — don't surface it as an
|
|
320
|
+
// error message because the TUI already shows abort feedback elsewhere.
|
|
321
|
+
if (m.stopReason === 'aborted') return
|
|
322
|
+
const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
|
|
323
|
+
send(ws, { type: 'error', message: text })
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function enqueuePrompt(ws: Ws, state: SessionState, msg: StreamMessage): void {
|
|
327
|
+
const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
|
|
328
|
+
if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
|
|
329
|
+
const delivery: PromptDelivery = payload.delivery ?? 'queue'
|
|
330
|
+
if (delivery === 'interrupt') {
|
|
331
|
+
void state.session.abort().catch((err) => {
|
|
332
|
+
send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
state.drainQueue.push({
|
|
336
|
+
streamMessageId: msg.id,
|
|
337
|
+
text: payload.text,
|
|
338
|
+
delivery,
|
|
339
|
+
ts: msg.ts,
|
|
340
|
+
})
|
|
341
|
+
pushQueueState(ws, state)
|
|
342
|
+
void drain(ws, state)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// `session.idle` semantically means "the agent finished a prompt and is now
|
|
346
|
+
// awaiting next input". Plugins (notably the bundled memory plugin) own any
|
|
347
|
+
// debouncing on top of this signal. Core fires the hook synchronously after
|
|
348
|
+
// every `prompt()` completion (success or error), passing the current
|
|
349
|
+
// transcript path so plugins can spawn subagents that read it.
|
|
350
|
+
function makeIdleHookCaller(state: SessionState): () => Promise<void> {
|
|
351
|
+
const hooks: HookBus | undefined = state.runtimeSnapshot?.hooks
|
|
352
|
+
if (hooks === undefined) return async () => {}
|
|
353
|
+
return async () => {
|
|
354
|
+
await hooks.runSessionIdle({
|
|
355
|
+
sessionId: state.sessionFileId,
|
|
356
|
+
parentTranscriptPath: state.sessionManager?.getSessionFile(),
|
|
357
|
+
idleMs: 0,
|
|
358
|
+
origin: state.origin,
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function drain(ws: Ws, state: SessionState): Promise<void> {
|
|
364
|
+
if (state.draining) return
|
|
365
|
+
state.draining = true
|
|
366
|
+
const fireIdle = makeIdleHookCaller(state)
|
|
367
|
+
try {
|
|
368
|
+
while (state.drainQueue.length > 0) {
|
|
369
|
+
const item = state.drainQueue.shift()
|
|
370
|
+
if (!item) break
|
|
371
|
+
pushQueueState(ws, state)
|
|
372
|
+
send(ws, { type: 'prompt_started', messageId: item.streamMessageId, text: item.text })
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
await state.session.prompt(item.text)
|
|
376
|
+
send(ws, { type: 'done' })
|
|
377
|
+
} catch (err) {
|
|
378
|
+
send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
|
|
379
|
+
}
|
|
380
|
+
await fireIdle()
|
|
381
|
+
}
|
|
382
|
+
} finally {
|
|
383
|
+
state.draining = false
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function pushQueueState(ws: Ws, state: SessionState): void {
|
|
388
|
+
const pending: QueueStateItem[] = state.drainQueue.map((q) => ({
|
|
389
|
+
id: q.streamMessageId,
|
|
390
|
+
text: q.text,
|
|
391
|
+
ts: q.ts,
|
|
392
|
+
}))
|
|
393
|
+
send(ws, { type: 'queue_state', pending })
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function handleReload(
|
|
397
|
+
ws: Ws,
|
|
398
|
+
reloadAll: ReloadAllFn | undefined,
|
|
399
|
+
reloadRegistry: ReloadRegistry | undefined,
|
|
400
|
+
scope: string | undefined,
|
|
401
|
+
): Promise<void> {
|
|
402
|
+
if (scope !== undefined && scope.length > 0) {
|
|
403
|
+
if (!reloadRegistry) {
|
|
404
|
+
send(ws, {
|
|
405
|
+
type: 'reload_result',
|
|
406
|
+
results: [{ scope, ok: false, reason: 'no reload registry configured' }],
|
|
407
|
+
})
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
const result = await reloadRegistry.reloadOne(scope)
|
|
412
|
+
send(ws, { type: 'reload_result', results: [result] })
|
|
413
|
+
} catch (err) {
|
|
414
|
+
send(ws, {
|
|
415
|
+
type: 'reload_result',
|
|
416
|
+
results: [{ scope, ok: false, reason: err instanceof Error ? err.message : String(err) }],
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (!reloadAll) {
|
|
423
|
+
const empty: ReloadResultPayload[] = []
|
|
424
|
+
send(ws, { type: 'reload_result', results: empty })
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
const { results } = await reloadAll()
|
|
429
|
+
send(ws, { type: 'reload_result', results })
|
|
430
|
+
} catch (err) {
|
|
431
|
+
send(ws, {
|
|
432
|
+
type: 'reload_result',
|
|
433
|
+
results: [{ scope: 'reload', ok: false, reason: err instanceof Error ? err.message : String(err) }],
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
5
|
+
|
|
6
|
+
export type SessionFactory = {
|
|
7
|
+
createPersisted(): SessionManager
|
|
8
|
+
sessionDir(): string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type CreateSessionFactoryOptions = {
|
|
12
|
+
agentDir: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createSessionFactory({ agentDir }: CreateSessionFactoryOptions): SessionFactory {
|
|
16
|
+
const dir = join(agentDir, 'sessions')
|
|
17
|
+
mkdirSync(dir, { recursive: true })
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
createPersisted: () => SessionManager.create(agentDir, dir),
|
|
21
|
+
sessionDir: () => dir,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function pad2(n: number): string {
|
|
2
|
+
return String(n).padStart(2, '0')
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function formatLocalDate(date: Date = new Date()): string {
|
|
6
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatLocalDateTime(date: Date = new Date()): string {
|
|
10
|
+
const datePart = formatLocalDate(date)
|
|
11
|
+
const timePart = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
|
|
12
|
+
const offset = formatTimezoneOffset(date)
|
|
13
|
+
return `${datePart}T${timePart}${offset}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatTimezoneOffset(date: Date): string {
|
|
17
|
+
const offsetMinutes = -date.getTimezoneOffset()
|
|
18
|
+
const sign = offsetMinutes >= 0 ? '+' : '-'
|
|
19
|
+
const abs = Math.abs(offsetMinutes)
|
|
20
|
+
return `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type ReloadResultPayload =
|
|
2
|
+
| { scope: string; ok: true; summary: string; details?: unknown }
|
|
3
|
+
| { scope: string; ok: false; reason: string }
|
|
4
|
+
|
|
5
|
+
export type PromptDelivery = 'queue' | 'steer' | 'interrupt'
|
|
6
|
+
|
|
7
|
+
export type ClientMessage =
|
|
8
|
+
| { type: 'prompt'; text: string; delivery?: PromptDelivery }
|
|
9
|
+
| { type: 'reload'; scope?: string }
|
|
10
|
+
| { type: 'abort' }
|
|
11
|
+
| { type: 'queue_cancel'; messageId: string }
|
|
12
|
+
|
|
13
|
+
export type QueueStateItem = { id: string; text: string; ts: number }
|
|
14
|
+
|
|
15
|
+
export type ServerMessage =
|
|
16
|
+
| { type: 'connected'; sessionId: string }
|
|
17
|
+
| { type: 'text_delta'; delta: string }
|
|
18
|
+
| { type: 'tool_start'; toolCallId: string; name: string; args: unknown }
|
|
19
|
+
| { type: 'tool_end'; toolCallId: string; name: string; error: boolean; result: unknown; durationMs: number }
|
|
20
|
+
| { type: 'done' }
|
|
21
|
+
| { type: 'error'; message: string }
|
|
22
|
+
| { type: 'reload_result'; results: ReloadResultPayload[] }
|
|
23
|
+
| { type: 'notification'; payload: unknown; replyTo?: string; meta?: Record<string, string> }
|
|
24
|
+
| { type: 'queue_state'; pending: QueueStateItem[] }
|
|
25
|
+
| { type: 'prompt_started'; messageId: string; text: string }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: typeclaw-channel-kakaotalk
|
|
3
|
+
description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `kakaotalk`. KakaoTalk renders messages as plain text — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and other markdown all appear literally. There is no `@mention` syntax, no message threads, no replies-with-quote, and no file attachments. Read it before composing anything for KakaoTalk so you don't dump markdown into a chat window.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# typeclaw-channel-kakaotalk
|
|
7
|
+
|
|
8
|
+
You are speaking through the `kakaotalk` channel adapter. The adapter wraps `agent-messenger`'s KakaoTalkClient/KakaoTalkListener, which speaks KakaoTalk's LOCO push protocol as a registered tablet sub-device of a real user account.
|
|
9
|
+
|
|
10
|
+
This means **you are messaging as a person, not as a bot.** Other participants see your typeclaw account's name and profile photo. Match their tone — the people you're talking with assume they're talking to a human.
|
|
11
|
+
|
|
12
|
+
## What KakaoTalk does NOT support
|
|
13
|
+
|
|
14
|
+
If you produce any of the following, KakaoTalk will render it literally and the recipient will see the raw markup:
|
|
15
|
+
|
|
16
|
+
- **Bold / italic / strikethrough** — `**bold**` shows as `**bold**`. Drop the asterisks; emphasize with word choice or capitalization (sparingly).
|
|
17
|
+
- **Headings** — `# H1`, `## H2`, `### H3` all render as raw `#` characters.
|
|
18
|
+
- **Tables** — pipe-delimited tables become a wall of `|` characters. Use bullet lists or short prose paragraphs instead.
|
|
19
|
+
- **Code fences** — ``` blocks render as raw backticks. For short snippets, paste the code inline. For long snippets, summarize and offer to send it via another channel.
|
|
20
|
+
- **Inline code** — `` `foo` `` renders as `` `foo` ``. Just write `foo`.
|
|
21
|
+
- **Links with display text** — `[label](url)` becomes the literal string. Send the bare URL on its own; the KakaoTalk client will auto-link it.
|
|
22
|
+
- **Mentions** — there is no `@user` syntax that the protocol surfaces. Address people by name in the message body.
|
|
23
|
+
- **Threads / replies-with-quote** — every message is a top-level chat post. There is no per-message reply UI.
|
|
24
|
+
- **Attachments** — the adapter is text-only. If the user asks you to send a file, say so and offer an alternative (paste a link, summarize the file, ship it via another channel).
|
|
25
|
+
|
|
26
|
+
The adapter logs a warning the first time you try to send attachments and then drops them. The user-visible result is "your message arrived without the file."
|
|
27
|
+
|
|
28
|
+
## What KakaoTalk DOES support
|
|
29
|
+
|
|
30
|
+
- Plain UTF-8 text. Emoji are fine.
|
|
31
|
+
- URLs auto-linkify in the client. Send them bare — `https://example.com/foo`, no markdown wrapping.
|
|
32
|
+
- Newlines render as line breaks. You can use `\n\n` to space paragraphs.
|
|
33
|
+
|
|
34
|
+
## Message length & cadence
|
|
35
|
+
|
|
36
|
+
KakaoTalk is mobile-first. The reading surface is small and the user is on their phone. Keep messages **short and conversational**, not essay-length. If you have a long answer:
|
|
37
|
+
|
|
38
|
+
1. Lead with a one-sentence summary.
|
|
39
|
+
2. Optional: 2–4 short bullet-style lines (use `-` or `•` as line prefixes — the client renders them as text, not lists, but the visual rhythm still helps).
|
|
40
|
+
3. Stop. If the user wants more, they will ask.
|
|
41
|
+
|
|
42
|
+
Splitting one logical answer across multiple messages is fine and often more natural than one wall of text.
|
|
43
|
+
|
|
44
|
+
## Engagement model
|
|
45
|
+
|
|
46
|
+
The adapter exposes three engagement triggers via `channels.kakaotalk.engagement.trigger` in `typeclaw.json`:
|
|
47
|
+
|
|
48
|
+
- `dm` — every message in a 1:1 chat. Default-on.
|
|
49
|
+
- `reply` — every message in a chat where you sent the previous message. Default-on.
|
|
50
|
+
- `mention` — KakaoTalk has no mention syntax in the protocol. The adapter reads this trigger as "respond when an alias from `alias[]` in `typeclaw.json` appears in the text". Without configured aliases, this trigger never fires.
|
|
51
|
+
|
|
52
|
+
Stickiness behaves the same as Slack/Discord: once you've engaged in a chat, follow-up messages within `engagement.stickiness.perReply.window` ms will route to you regardless of trigger.
|
|
53
|
+
|
|
54
|
+
If you find yourself NOT receiving messages you expect to, the most likely cause is the `allow` list. KakaoTalk uses a different grammar from Slack/Discord:
|
|
55
|
+
|
|
56
|
+
- `kakao:*` — every chat the account can see (use sparingly: this is every group and DM you are a member of)
|
|
57
|
+
- `kakao:dm/*` — every 1:1 chat
|
|
58
|
+
- `kakao:group/*` — every multi-person group chat
|
|
59
|
+
- `kakao:open/*` — every open chat
|
|
60
|
+
- `kakao:<chat-id>` — a specific chat by numeric chat_id
|
|
61
|
+
|
|
62
|
+
The init wizard's default is `kakao:dm/*` because group chats with personal accounts are sensitive — every member sees every reply. Only broaden the allow list when the user explicitly asks.
|
|
63
|
+
|
|
64
|
+
## Mark read on every inbound
|
|
65
|
+
|
|
66
|
+
The adapter sends a LOCO `NOTIREAD` ack to KakaoTalk for every inbound message event it observes. The sender's unread "1" (노란숫자) clears in their client as soon as the agent's container receives the bytes. This is always-on — there is no config flag to turn it off short of editing the source. (An earlier `channels.kakaotalk.autoMarkRead` opt-in field was removed; existing configs still parse but the field is ignored.)
|
|
67
|
+
|
|
68
|
+
Things to know about this behavior:
|
|
69
|
+
|
|
70
|
+
- Auto-acking every received message is a distinct behavioral fingerprint compared to a human. A human reads messages when they open the chat; this adapter acks every received message instantly, even ones you never reply to. KakaoTalk's abuse detection may flag accounts that ack rapidly and unconditionally. **Run the kakaotalk adapter only on dedicated agent accounts you can afford to lose.**
|
|
71
|
+
- Dropped messages are still acked. If classify drops the message (your own self-sent loopback, empty text, sender not in `allow`), the unread "1" still clears — the agent has observed the bytes, so the read indicator should match.
|
|
72
|
+
- Open chats (오픈채팅) are skipped: the LOCO `NOTIREAD` packet needs a `linkId` for open chats and the adapter doesn't surface it yet. Unread counters in open chats will not decrement.
|
|
73
|
+
- The phone's home-screen OS unread badge may lag until the phone client foregrounds; the in-chat counter and other participants' indicators update immediately. KakaoTalk client quirk, not a typeclaw bug.
|
|
74
|
+
|
|
75
|
+
If a markRead call fails (network blip, non-success status from the server, container shutdown), it is logged at warn level (`[kakaotalk] mark-read failed: ...`) and silently moves on — message delivery is never blocked by an ack failure.
|
|
76
|
+
|
|
77
|
+
## Self-loop guard
|
|
78
|
+
|
|
79
|
+
The adapter drops every inbound where `event.author_id` equals the logged-in account's `user_id`. Two typeclaw agents talking to each other through KakaoTalk would otherwise loop indefinitely (KakaoTalk has no bot-flag, so neither side can detect the other is automated). Do not try to defeat this guard.
|
|
80
|
+
|
|
81
|
+
## When you cannot answer in KakaoTalk
|
|
82
|
+
|
|
83
|
+
If the user asks you to do something the adapter cannot do (send a file, render markdown, post in a thread), say so plainly:
|
|
84
|
+
|
|
85
|
+
> "I can't attach files through this chat. Want me to drop the file in [other channel] instead?"
|
|
86
|
+
|
|
87
|
+
Better than silently dropping the attachment and pretending you sent it.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: typeclaw-channel-telegram-bot
|
|
3
|
+
description: "How replies sent through the `telegram-bot` channel adapter actually render in Telegram. Read this BEFORE composing a reply that contains formatting markers — `**bold**`, `*italic*`, `_italic_`, `` `code` ``, fenced code blocks (```), `[label](url)`, `~~strike~~`, `||spoiler||`, `# heading`, `- list`, `| table |`, raw `.` `!` `(` `)` `_` `*` punctuation in URLs/IDs, snake_case identifiers — and your draft is going to a Telegram chat or supergroup. Also load if a Telegram user reports your message arrived as raw markdown (literal `**asterisks**`), as garbled text with stray backslashes, or with a Telegram error like `Bad Request: can't parse entities`. Covers: which markers actually render, which ones are stripped or escaped, what Telegram has no equivalent for (headings, bulleted/numbered lists, tables — they fall through as escaped literals), how the adapter's MarkdownV2 escaping behaves, and how to keep your message readable when the rendering and the source diverge."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# typeclaw-channel-telegram-bot
|
|
7
|
+
|
|
8
|
+
When you reply through the `telegram-bot` channel adapter, your text does NOT go to Telegram unchanged. It goes through `toTelegramMarkdownV2` in `src/channels/adapters/telegram-bot-format.ts`, which translates the common Markdown you write into Telegram's strict **MarkdownV2** dialect and escapes every reserved char that isn't part of a recognized formatting marker. The Telegram Bot API is then called with `parse_mode: 'MarkdownV2'`.
|
|
9
|
+
|
|
10
|
+
This is necessary because Telegram's MarkdownV2 parser is unforgiving: any unescaped `_ * [ ] ( ) ~ \` > # + - = | { } . !`outside an entity returns`Bad Request: can't parse entities`and the whole message is rejected. Plain text would never crash, but then your`**bold**`would render as the literal six characters`**bold**` — which is the bug this skill exists to prevent you from re-introducing on the user side ("just write what you mean, the adapter will handle it").
|
|
11
|
+
|
|
12
|
+
## What the adapter renders
|
|
13
|
+
|
|
14
|
+
These markers translate to native Telegram formatting:
|
|
15
|
+
|
|
16
|
+
- `**bold**` → bold (renders as `*bold*` on the wire — MarkdownV2 reserves `*` for bold)
|
|
17
|
+
- `__bold__` → bold (when not adjacent to a word char on either side; otherwise treated as literal underscores so `my__var__name` stays a snake_case identifier)
|
|
18
|
+
- `*italic*` → italic (when the asterisks are NOT between word chars — `a*b*c` stays literal)
|
|
19
|
+
- `_italic_` → italic (same word-boundary rule as `*italic*` — `var_name` stays an identifier)
|
|
20
|
+
- `` `inline code` `` → monospace inline code
|
|
21
|
+
- ` ```language\n...code...\n``` ` → fenced code block; the optional language tag rides through (Telegram displays it but does no syntax highlighting)
|
|
22
|
+
- `[label](url)` → clickable hyperlink
|
|
23
|
+
- `~~strike~~` → strikethrough
|
|
24
|
+
- `||spoiler||` → spoiler (tap to reveal)
|
|
25
|
+
|
|
26
|
+
Empty markers (`****`, ` `` `, `~~~~`, `||||`) are NOT emitted as zero-width entities; they fall through as escaped literals. Telegram rejects empty entities, and the adapter's safety pass catches this for you.
|
|
27
|
+
|
|
28
|
+
## What the adapter does NOT render
|
|
29
|
+
|
|
30
|
+
Telegram's MarkdownV2 has no native rendering for any of these. They fall through as escaped literal characters — visible in the message, but not formatted:
|
|
31
|
+
|
|
32
|
+
- `# Heading`, `## Heading 2`, etc. — appear as `\# Heading` / `\## Heading 2` (the `#` shows but it's not a heading)
|
|
33
|
+
- `- item` / `* item` / `1. item` (bulleted or numbered lists) — the markers escape to `\-` / `\*` / `1\.` and the message stays a plain paragraph with line breaks; Telegram users see the dash or number, not a styled list
|
|
34
|
+
- `| col | col |\n|---|---|\n` (Markdown tables) — every `|` and `-` escapes; the result is illegible. Don't send tables. Send a fenced code block with aligned columns instead.
|
|
35
|
+
- `> quote` — the `>` escapes to `\>`; Telegram does have a native blockquote syntax but the agent's common-Markdown writer has no way to opt into it through this adapter today
|
|
36
|
+
- HTML tags (`<b>`, `<i>`, `<a>`) — the adapter does NOT use HTML mode; tag chars escape and the literal `<b>foo</b>` shows up
|
|
37
|
+
|
|
38
|
+
When you need any of these effects, **rewrite the message** to use what Telegram does support (bold for emphasis instead of headings; bullet points written as separate lines with bold leading words instead of `-` markers; aligned-column code fences instead of tables).
|
|
39
|
+
|
|
40
|
+
## Punctuation rules you can stop worrying about
|
|
41
|
+
|
|
42
|
+
You do NOT need to manually escape any of `_ * [ ] ( ) ~ \` > # + - = | { } . !` in your draft. The adapter handles it.
|
|
43
|
+
|
|
44
|
+
- Periods, exclamation points, hyphens, plus signs in prose — type them naturally; the adapter escapes them.
|
|
45
|
+
- Snake_case identifiers (`my_var_name`, `foo__bar__baz`) — type them naturally; the word-boundary guards keep them from italicizing.
|
|
46
|
+
- Math/code asterisks in prose (`a*b*c`, `2 * 3 = 6`) — same; the word-boundary guards keep them literal.
|
|
47
|
+
- URLs containing `_`, `.`, `-`, `~` — they pass through fine inside `[label](url)`.
|
|
48
|
+
|
|
49
|
+
URLs containing **unescaped parentheses** (Wikipedia-style `Foo_(bar)`) intentionally fall back to escaped literal text rather than render as a link — the adapter cannot disambiguate the closing `)` from a content paren. If you need to link such a URL, percent-encode the parens in the URL (`%28`, `%29`) before putting it in the link.
|
|
50
|
+
|
|
51
|
+
## When the user says "your formatting looks broken"
|
|
52
|
+
|
|
53
|
+
Three classes of failure to triage in this order:
|
|
54
|
+
|
|
55
|
+
1. **Literal `**asterisks**` in the rendered message.** Means the adapter was bypassed (someone shipped a regression that flipped `parse_mode` off). Check `src/channels/adapters/telegram-bot.ts` — `sendMessage` MUST include `{ parse_mode: 'MarkdownV2' }` and the text must come from `toTelegramMarkdownV2(...)`. The mutation-guard test in `telegram-bot-outbound.test.ts` exists exactly to catch this.
|
|
56
|
+
2. **Visible backslashes (e.g. `v1\.2\.3`).** Means the user is on a Telegram client too old to honor MarkdownV2 entity-rendering, OR the message was forwarded to a context that doesn't (rare). Nothing the adapter can do — the wire format is correct.
|
|
57
|
+
3. **Telegram error: `Bad Request: can't parse entities`.** Means the formatter emitted invalid MarkdownV2 — either an entity-rule edge case the formatter mishandled, or the agent wrote something pathological (e.g. a literal MarkdownV2-escaped fragment by hand). File a bug against the formatter with the exact input string; do not work around it by sending plain text (that loses formatting for everyone).
|
|
58
|
+
|
|
59
|
+
## File pointers
|
|
60
|
+
|
|
61
|
+
- `src/channels/adapters/telegram-bot.ts` — adapter; `createOutboundCallback` is where rendering happens.
|
|
62
|
+
- `src/channels/adapters/telegram-bot-format.ts` — the formatter; pure, no dependencies, fully tested.
|
|
63
|
+
- `src/channels/adapters/telegram-bot-format.test.ts` — every formatter rule above is mutation-guarded by a named test here.
|
|
64
|
+
- `src/channels/adapters/telegram-bot-outbound.test.ts` — the integration mutation guard that pins `parse_mode: 'MarkdownV2'`.
|