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,182 @@
1
+ import { Type } from '@mariozechner/pi-ai'
2
+ import { defineTool } from '@mariozechner/pi-coding-agent'
3
+
4
+ import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
5
+ import type { AdapterId } from '@/channels/schema'
6
+
7
+ export type ChannelReplyOrigin = {
8
+ adapter: AdapterId
9
+ workspace: string
10
+ chat: string
11
+ thread: string | null
12
+ }
13
+
14
+ export type CreateChannelReplyToolOptions = {
15
+ router: ChannelRouter
16
+ origin: ChannelReplyOrigin
17
+ }
18
+
19
+ // channel_reply is the happy-path companion to channel_send for channel-routed
20
+ // sessions. The session's origin already pins the conversation we're inside
21
+ // (adapter, workspace, chat, thread), so the model shouldn't have to copy
22
+ // those fields verbatim every turn — that copying is exactly where it has
23
+ // historically dropped `thread` and posted to channel root by accident.
24
+ //
25
+ // channel_reply takes only `text` and addresses the message from the origin.
26
+ // channel_send remains for posting somewhere else (different chat, breaking
27
+ // out of a thread, sending DMs from a channel session, etc.).
28
+ export function createChannelReplyTool({ router, origin }: CreateChannelReplyToolOptions) {
29
+ return defineTool({
30
+ name: 'channel_reply',
31
+ label: 'Channel Reply',
32
+ description:
33
+ 'Reply in the current conversation. This is your default way to respond to a channel session — ' +
34
+ 'addressing fields (adapter, workspace, chat, thread) are filled in from the session origin, so ' +
35
+ 'you only supply the text. To post somewhere else (different chat, break out of the current ' +
36
+ 'thread, etc.), use `channel_send` instead.',
37
+ parameters: Type.Object({
38
+ text: Type.Optional(
39
+ Type.String({
40
+ description:
41
+ 'The message body. Use platform mention syntax `<@USER_ID>` for Slack/Discord mentions. Optional only when `attachments` is set.',
42
+ minLength: 1,
43
+ }),
44
+ ),
45
+ attachments: Type.Optional(
46
+ Type.Array(
47
+ Type.Object({
48
+ path: Type.String({
49
+ description: 'Absolute path inside the agent container to the file to upload.',
50
+ minLength: 1,
51
+ }),
52
+ filename: Type.Optional(Type.String({ minLength: 1 })),
53
+ }),
54
+ {
55
+ description:
56
+ 'Optional files to attach. Slack folds `text` into the first file as a caption (single message). Discord uploads files separately and may post `text` as a follow-up message; uploaded files land in channel root even when replying inside a thread (upstream limitation).',
57
+ minItems: 1,
58
+ },
59
+ ),
60
+ ),
61
+ }),
62
+
63
+ async execute(_toolCallId, params) {
64
+ const text = params.text
65
+ const attachments = params.attachments
66
+ if ((text === undefined || text === '') && (attachments === undefined || attachments.length === 0)) {
67
+ return {
68
+ content: [
69
+ { type: 'text' as const, text: 'channel_reply denied: must provide `text`, `attachments`, or both.' },
70
+ ],
71
+ details: { ok: false, error: 'missing text and attachments' },
72
+ }
73
+ }
74
+
75
+ const noReplyError = noReplyMisuseError(text)
76
+ if (noReplyError) {
77
+ return {
78
+ content: [{ type: 'text' as const, text: `channel_reply denied: ${noReplyError}` }],
79
+ details: { ok: false, error: noReplyError },
80
+ }
81
+ }
82
+
83
+ const result = await router.send({
84
+ adapter: origin.adapter,
85
+ workspace: origin.workspace,
86
+ chat: origin.chat,
87
+ thread: origin.thread,
88
+ ...(text !== undefined ? { text } : {}),
89
+ ...(attachments !== undefined ? { attachments } : {}),
90
+ })
91
+
92
+ const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
93
+ // Echo the delivered text back to the model. The adapter classifier
94
+ // drops self-authored messages on the inbound path (`self_author`),
95
+ // so the bot otherwise has ZERO visibility into what it just said —
96
+ // not in the next iteration's context, not in later turns' history.
97
+ // Without this echo, a model that splits a multi-part reply has no
98
+ // way to tell "did I already send part 1?" from "I haven't started
99
+ // yet", and routinely re-sends near-duplicates within the same turn
100
+ // (observed in production: two consecutive identical
101
+ // greeting messages to one prompt).
102
+ //
103
+ // We deliberately do NOT cap sends-per-turn here. A complex user
104
+ // request legitimately needs split replies, and a hard cap would
105
+ // mutilate that. The fix is to give the model honest feedback —
106
+ // show it what it sent, let it decide whether to continue.
107
+ // Truncate past 500 chars so a long reply doesn't double the prompt
108
+ // size on every subsequent iteration; the prefix is enough to detect
109
+ // duplication, and the full text is recoverable from the session
110
+ // JSONL if needed.
111
+ const echo = renderOutboundEcho(text, attachments)
112
+ const baseText = result.ok
113
+ ? `posted to ${origin.adapter}:${origin.workspace}/${origin.chat}: ${echo}`
114
+ : `channel_reply denied: ${result.error}`
115
+ const hint = result.ok
116
+ ? consecutiveSendHint(
117
+ router.getConsecutiveSendCount({
118
+ adapter: origin.adapter,
119
+ workspace: origin.workspace,
120
+ chat: origin.chat,
121
+ thread: origin.thread,
122
+ }),
123
+ )
124
+ : ''
125
+ return {
126
+ content: [{ type: 'text' as const, text: hint ? `${baseText} — ${hint}` : baseText }],
127
+ details,
128
+ }
129
+ },
130
+ })
131
+ }
132
+
133
+ export const ECHO_MAX_CHARS = 500
134
+
135
+ export function renderEcho(text: string): string {
136
+ if (text.length <= ECHO_MAX_CHARS) return JSON.stringify(text)
137
+ return `${JSON.stringify(text.slice(0, ECHO_MAX_CHARS))}... (${text.length} chars total)`
138
+ }
139
+
140
+ export function renderOutboundEcho(
141
+ text: string | undefined,
142
+ attachments: ReadonlyArray<{ path: string; filename?: string }> | undefined,
143
+ ): string {
144
+ const hasText = text !== undefined && text !== ''
145
+ const hasAttachments = attachments !== undefined && attachments.length > 0
146
+ if (hasText && hasAttachments) {
147
+ const filenames = attachments.map((a) => a.filename ?? a.path.split('/').pop() ?? a.path)
148
+ return `${renderEcho(text)} + ${attachments.length} file(s): ${filenames.join(', ')}`
149
+ }
150
+ if (hasText) return renderEcho(text)
151
+ if (hasAttachments) {
152
+ const filenames = attachments.map((a) => a.filename ?? a.path.split('/').pop() ?? a.path)
153
+ return `${attachments.length} file(s): ${filenames.join(', ')}`
154
+ }
155
+ return '(empty)'
156
+ }
157
+
158
+ // Mirror of the same guard used by channel_send. Blocks any silent-turn
159
+ // signal (per `isNoReplySignal`) from being sent as a message body — same
160
+ // misuse, same denial, regardless of which sending tool the model picked.
161
+ // Returns '' when text is undefined (attachments-only reply, can't be
162
+ // misusing the signal) or when text is non-empty and not a signal.
163
+ function noReplyMisuseError(text: string | undefined): string {
164
+ if (text === undefined) return ''
165
+ if (text.trim() === '') return ''
166
+ if (!isNoReplySignal(text)) return ''
167
+ return (
168
+ '`NO_REPLY` is the silent-turn signal, not a message body. ' +
169
+ 'To stay silent, end your turn with `NO_REPLY` as your entire visible response and NO channel tool call. ' +
170
+ 'To send an actual reply, call this tool again with different text.'
171
+ )
172
+ }
173
+
174
+ // Mirror of the same hint used by channel_send. Kept identical so the model
175
+ // sees the same yield signal regardless of which tool it picked.
176
+ function consecutiveSendHint(countAfterSend: number): string {
177
+ if (countAfterSend <= 1) return ''
178
+ if (countAfterSend === 2) {
179
+ return 'this is your 2nd consecutive message in this conversation; continue only if the reply genuinely needs splitting.'
180
+ }
181
+ return `${countAfterSend}th consecutive message with no user reply; end your turn now unless the user explicitly asked for a multi-step response.`
182
+ }
@@ -0,0 +1,212 @@
1
+ import { Type } from '@mariozechner/pi-ai'
2
+ import { defineTool } from '@mariozechner/pi-coding-agent'
3
+
4
+ import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
5
+ import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
6
+
7
+ import { renderOutboundEcho } from './channel-reply'
8
+
9
+ export type ChannelSendOrigin = {
10
+ adapter: AdapterId
11
+ workspace: string
12
+ chat: string
13
+ thread: string | null
14
+ }
15
+
16
+ export type CreateChannelSendToolOptions = {
17
+ router: ChannelRouter
18
+ // Optional channel origin for the session this tool is wired into. When
19
+ // present, the tool can detect "you posted to the same conversation but
20
+ // dropped the thread" and surface that as a hint in the tool result, so
21
+ // the model can self-correct on its next turn. Absent for sessions whose
22
+ // origin isn't a channel (e.g. cron prompts that send to channels).
23
+ origin?: ChannelSendOrigin
24
+ }
25
+
26
+ export function createChannelSendTool({ router, origin }: CreateChannelSendToolOptions) {
27
+ return defineTool({
28
+ name: 'channel_send',
29
+ label: 'Channel Send',
30
+ description:
31
+ 'Post a message to an external messenger channel. Specify adapter, workspace, chat, and text. ' +
32
+ 'For Discord guild channels, workspace is the guild id; for Slack team channels, workspace is ' +
33
+ 'the team id (e.g. "T0ACME"). For DMs on either platform, workspace is the literal "@dm". ' +
34
+ 'The runtime checks the channel allow rules before delivering — if the target chat is not in ' +
35
+ 'the configured allow list, the call fails with { ok: false, error }. There is no auto-reply: ' +
36
+ 'the only way for an agent to post is via this tool.',
37
+ parameters: Type.Object({
38
+ adapter: Type.Union(
39
+ ADAPTER_IDS.map((a) => Type.Literal(a)),
40
+ { description: 'Adapter id. Supported: "discord-bot", "slack-bot".' },
41
+ ),
42
+ workspace: Type.String({
43
+ description:
44
+ 'Discord guild id or Slack team id (e.g. "T0ACME"); use "@dm" for direct-message channels on either platform.',
45
+ minLength: 1,
46
+ }),
47
+ chat: Type.String({
48
+ description:
49
+ 'Channel id. Discord channel id (numeric snowflake) or Slack channel id (e.g. "C0CHANNEL", "D0DMID").',
50
+ minLength: 1,
51
+ }),
52
+ thread: Type.Optional(
53
+ Type.String({
54
+ description:
55
+ 'Optional thread id. For Discord, the thread channel id. For Slack, the parent message thread_ts.',
56
+ minLength: 1,
57
+ }),
58
+ ),
59
+ text: Type.Optional(
60
+ Type.String({
61
+ description:
62
+ 'The message body. Use Discord syntax `<@USER_ID>` for Discord mentions or Slack syntax `<@USER_ID>` for Slack mentions (Slack user ids start with "U"). Optional only when `attachments` is set; one of `text` or `attachments` must be present.',
63
+ minLength: 1,
64
+ }),
65
+ ),
66
+ attachments: Type.Optional(
67
+ Type.Array(
68
+ Type.Object({
69
+ path: Type.String({
70
+ description:
71
+ 'Absolute path inside the agent container to the file to upload (e.g. "/agent/workspace/report.pdf"). The runtime reads the file just before the API call.',
72
+ minLength: 1,
73
+ }),
74
+ filename: Type.Optional(
75
+ Type.String({
76
+ description:
77
+ 'Filename to display in the chat. Defaults to the basename of `path`. Useful when the on-disk name carries a tempdir suffix the user should not see.',
78
+ minLength: 1,
79
+ }),
80
+ ),
81
+ }),
82
+ {
83
+ description:
84
+ "Optional files to upload alongside the text. Slack: `text` is sent as the first file's caption (single Slack message). Discord: each file is uploaded individually (no caption support upstream), then `text` is posted as a separate message; uploads land in the channel root even when `thread` is set.",
85
+ minItems: 1,
86
+ },
87
+ ),
88
+ ),
89
+ }),
90
+
91
+ async execute(_toolCallId, params) {
92
+ const adapter = params.adapter as AdapterId
93
+ const bodyText = params.text
94
+ const attachments = params.attachments
95
+ if ((bodyText === undefined || bodyText === '') && (attachments === undefined || attachments.length === 0)) {
96
+ return {
97
+ content: [
98
+ { type: 'text' as const, text: 'channel_send denied: must provide `text`, `attachments`, or both.' },
99
+ ],
100
+ details: { ok: false, error: 'missing text and attachments' },
101
+ }
102
+ }
103
+
104
+ const noReplyError = noReplyMisuseError(bodyText)
105
+ if (noReplyError) {
106
+ return {
107
+ content: [{ type: 'text' as const, text: `channel_send denied: ${noReplyError}` }],
108
+ details: { ok: false, error: noReplyError },
109
+ }
110
+ }
111
+
112
+ const result = await router.send({
113
+ adapter,
114
+ workspace: params.workspace,
115
+ chat: params.chat,
116
+ ...(params.thread !== undefined ? { thread: params.thread } : {}),
117
+ ...(bodyText !== undefined ? { text: bodyText } : {}),
118
+ ...(attachments !== undefined ? { attachments } : {}),
119
+ })
120
+
121
+ const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
122
+ const echo = renderOutboundEcho(bodyText, attachments)
123
+ const baseText = result.ok
124
+ ? `posted to ${params.adapter}:${params.workspace}/${params.chat}: ${echo}`
125
+ : `channel_send denied: ${result.error}`
126
+ const hints: string[] = []
127
+ if (result.ok) {
128
+ const consecutive = consecutiveSendHint(
129
+ router.getConsecutiveSendCount({
130
+ adapter,
131
+ workspace: params.workspace,
132
+ chat: params.chat,
133
+ thread: params.thread ?? null,
134
+ }),
135
+ )
136
+ if (consecutive) hints.push(consecutive)
137
+
138
+ const threadMismatch = threadMismatchHint(origin, {
139
+ adapter,
140
+ workspace: params.workspace,
141
+ chat: params.chat,
142
+ thread: params.thread,
143
+ })
144
+ if (threadMismatch) hints.push(threadMismatch)
145
+ }
146
+ const responseText = hints.length > 0 ? `${baseText} — ${hints.join(' ')}` : baseText
147
+ return {
148
+ content: [{ type: 'text' as const, text: responseText }],
149
+ details,
150
+ }
151
+ },
152
+ })
153
+ }
154
+
155
+ // Returns a behavioral hint when the model posted to the SAME conversation
156
+ // as the session's origin (same adapter+workspace+chat) but DROPPED the
157
+ // thread. This catches the "model forgot to copy thread verbatim" failure
158
+ // mode without blocking legitimate intent — if leaving the thread was on
159
+ // purpose ("새 스레드에서 시작하자"), the model can ignore this hint; if it
160
+ // wasn't, the next channel_send (or channel_reply) can correct course.
161
+ //
162
+ // Only fires when the origin had a thread to begin with — channel-root
163
+ // sessions can't have a "missing thread" problem.
164
+ function threadMismatchHint(
165
+ origin: ChannelSendOrigin | undefined,
166
+ sent: { adapter: AdapterId; workspace: string; chat: string; thread: string | undefined },
167
+ ): string {
168
+ if (!origin) return ''
169
+ if (origin.thread === null) return ''
170
+ if (sent.thread !== undefined) return ''
171
+ if (origin.adapter !== sent.adapter) return ''
172
+ if (origin.workspace !== sent.workspace) return ''
173
+ if (origin.chat !== sent.chat) return ''
174
+ return (
175
+ `note: this session's origin thread is ${JSON.stringify(origin.thread)} but you posted to channel root. ` +
176
+ `if breaking out of the thread was intentional, ignore this; otherwise prefer \`channel_reply\` ` +
177
+ `or pass \`thread: ${JSON.stringify(origin.thread)}\` on your next channel_send.`
178
+ )
179
+ }
180
+
181
+ // Blocks a specific misuse: the model tried to send a silent-turn signal
182
+ // (e.g. `NO_REPLY`, `(NO_REPLY)`) as a channel message. Those forms belong
183
+ // in the model's *visible response* when no channel tool is called (see
184
+ // session-origin.ts and router.ts validateChannelTurn), NOT in the body of
185
+ // a sent message. We short-circuit BEFORE router.send so the signal never
186
+ // reaches the chat. Detection delegates to `isNoReplySignal` so the router
187
+ // and both tools stay in lockstep. Empty/undefined text is fine — that
188
+ // means "attachments-only send", not a signal.
189
+ function noReplyMisuseError(text: string | undefined): string {
190
+ if (text === undefined) return ''
191
+ if (text.trim() === '') return ''
192
+ if (!isNoReplySignal(text)) return ''
193
+ return (
194
+ '`NO_REPLY` is the silent-turn signal, not a message body. ' +
195
+ 'To stay silent, end your turn with `NO_REPLY` as your entire visible response and NO channel tool call. ' +
196
+ 'To send an actual reply, call this tool again with different text.'
197
+ )
198
+ }
199
+
200
+ // Returns a behavioral hint to nudge the model toward yielding when it has
201
+ // been the only voice in the conversation for several messages. The router
202
+ // increments its counter AFTER router.send returns, so a count of 1 means
203
+ // "this is the second consecutive bot message in this chat:thread" — which
204
+ // is the first count where a hint is warranted. Empty string at count <= 1
205
+ // preserves the original tool-result text for the common single-reply case.
206
+ function consecutiveSendHint(countAfterSend: number): string {
207
+ if (countAfterSend <= 1) return ''
208
+ if (countAfterSend === 2) {
209
+ return 'this is your 2nd consecutive message in this conversation; continue only if the reply genuinely needs splitting.'
210
+ }
211
+ return `${countAfterSend}th consecutive message with no user reply; end your turn now unless the user explicitly asked for a multi-step response.`
212
+ }
@@ -0,0 +1,218 @@
1
+ // DDG's no-JS "lite" endpoint is the only major engine that serves a
2
+ // parseable, key-free, registration-free SERP. We POST a query and parse the
3
+ // resulting <table> markup.
4
+ //
5
+ // We target `lite.duckduckgo.com/lite/` rather than `html.duckduckgo.com/html/`
6
+ // because `html` is gated by the interactive "duck picker" CAPTCHA after a
7
+ // single bad fingerprint match. `lite` exists for non-browser clients (text
8
+ // browsers, accessibility tools) and historically gates less aggressively —
9
+ // but as of 2026 it ALSO fingerprints at the TLS layer (JA3/JA4) and the
10
+ // HTTP/2 SETTINGS frame, well before any HTTP header is read. Bun's native
11
+ // fetch cannot match Chrome's handshake (upstream issue #11368), so requests
12
+ // from `fetch()` get gated regardless of headers, body shape, or pacing —
13
+ // confirmed empirically over a multi-hour session against a single home IP
14
+ // where real Chromium succeeded continuously while every fetch variant got
15
+ // 202 anomaly-modal or HTTP-200-with-anomaly responses.
16
+ //
17
+ // The fix is to shell out to `curl-impersonate` (lexiforest fork), which
18
+ // replays Chrome's exact TLS handshake + HTTP/2 settings + header ordering.
19
+ // The binary is installed by the typeclaw Dockerfile (see
20
+ // src/init/dockerfile.ts CURL_IMPERSONATE_* constants) at /usr/local/bin/
21
+ // and invoked via the version-pinned wrapper `curl_chrome136`.
22
+ //
23
+ // Why no `-H` overrides: curl_chrome136 already sends the full Chrome 136
24
+ // header set with correct ordering, sec-ch-ua values, etc. Adding our own
25
+ // headers would corrupt the impersonation. The previous code's
26
+ // BROWSER_HEADERS const has been removed for the same reason.
27
+
28
+ import { spawn } from 'bun'
29
+
30
+ const DDG_LITE_URL = 'https://lite.duckduckgo.com/lite/'
31
+ const CURL_IMPERSONATE_BINARY = 'curl_chrome136'
32
+ const REQUEST_TIMEOUT_SECONDS = 30
33
+
34
+ let curlBinary = CURL_IMPERSONATE_BINARY
35
+
36
+ // Test-only seam: lets ddg.test.ts and websearch.test.ts point the spawn
37
+ // at a fake `curl_chrome136` script in a tmpdir so we exercise the real
38
+ // Bun.spawn path without depending on a curl-impersonate install on the
39
+ // test host. Production code never calls this — the const-import default
40
+ // above is what production sees.
41
+ export function _setCurlBinaryForTest(binary: string | null): void {
42
+ curlBinary = binary ?? CURL_IMPERSONATE_BINARY
43
+ }
44
+
45
+ export type DdgResult = {
46
+ title: string
47
+ url: string
48
+ snippet: string
49
+ }
50
+
51
+ export async function ddgSearch(query: string, limit: number, signal?: AbortSignal): Promise<DdgResult[]> {
52
+ const html = await fetchDdgHtml(query, signal)
53
+ if (isCaptcha(html)) {
54
+ throw new DdgCaptchaError()
55
+ }
56
+ return parseDdgHtml(html).slice(0, limit)
57
+ }
58
+
59
+ export class DdgCaptchaError extends Error {
60
+ constructor() {
61
+ super('DuckDuckGo returned a CAPTCHA page (rate-limited). Try again later or with a different query.')
62
+ this.name = 'DdgCaptchaError'
63
+ }
64
+ }
65
+
66
+ export async function fetchDdgHtml(query: string, signal?: AbortSignal): Promise<string> {
67
+ // Spawn detached so the child becomes the leader of its own process group.
68
+ // The curl-impersonate wrappers (curl_chrome136 et al.) are bash scripts
69
+ // that call the real curl-impersonate binary WITHOUT `exec` — meaning the
70
+ // wrapper is the parent and curl-impersonate is its child. On a plain
71
+ // SIGKILL to the wrapper PID, the curl child becomes orphaned and keeps
72
+ // the stdout pipe open until --max-time fires (30s default), turning a
73
+ // 50ms abort into a 30s hang. process.kill(-pid) addresses the negative
74
+ // PID, which signals the entire process group, killing both the wrapper
75
+ // and the inner curl atomically. detached: true is what makes the child
76
+ // the pgid leader so -pid is well-defined; without it, the child shares
77
+ // our pgid and we'd nuke our own process.
78
+ const proc = spawn({
79
+ cmd: [
80
+ curlBinary,
81
+ '--silent',
82
+ '--show-error',
83
+ '--fail-with-body',
84
+ '--compressed',
85
+ '--max-time',
86
+ String(REQUEST_TIMEOUT_SECONDS),
87
+ '-X',
88
+ 'POST',
89
+ '--data-urlencode',
90
+ `q=${query}`,
91
+ DDG_LITE_URL,
92
+ ],
93
+ stdout: 'pipe',
94
+ stderr: 'pipe',
95
+ detached: true,
96
+ })
97
+
98
+ const onAbort = () => {
99
+ try {
100
+ process.kill(-proc.pid, 'SIGKILL')
101
+ } catch {
102
+ proc.kill('SIGKILL')
103
+ }
104
+ }
105
+ signal?.addEventListener('abort', onAbort, { once: true })
106
+
107
+ try {
108
+ const [stdout, stderr, exitCode] = await Promise.all([
109
+ new Response(proc.stdout).text(),
110
+ new Response(proc.stderr).text(),
111
+ proc.exited,
112
+ ])
113
+
114
+ if (signal?.aborted) {
115
+ throw new Error('aborted')
116
+ }
117
+ if (exitCode !== 0) {
118
+ const detail = stderr.trim() || 'no stderr'
119
+ throw new Error(`curl-impersonate exited ${exitCode}: ${detail}`)
120
+ }
121
+ return stdout
122
+ } finally {
123
+ signal?.removeEventListener('abort', onAbort)
124
+ }
125
+ }
126
+
127
+ // The `lite` endpoint's CAPTCHA page is plainer than `html`'s anomaly-modal:
128
+ // it returns either an HTTP error (caught above) or a "challenge-form" page
129
+ // asking the user to verify they're human. We also keep the legacy anomaly
130
+ // markers as a belt-and-suspenders check in case DDG ever unifies the flows.
131
+ function isCaptcha(html: string): boolean {
132
+ return (
133
+ html.includes('challenge-form') ||
134
+ html.includes('Please verify you are a human') ||
135
+ html.includes('anomaly-modal') ||
136
+ html.includes('class="anomaly"')
137
+ )
138
+ }
139
+
140
+ // Parses the lite SERP HTML. Each result is a triplet of `<tr>` rows:
141
+ // 1. <a class='result-link' href="…">Title</a>
142
+ // 2. <td class='result-snippet'>snippet…</td>
143
+ // 3. <span class='link-text'>display.url</span>
144
+ // Rows 2 and 3 are sometimes absent (e.g. ad placements without snippets), so
145
+ // we anchor on `result-link` and walk forward looking for the optional
146
+ // snippet within a small window. Sponsored entries are wrapped in adjacent
147
+ // rows that don't carry `result-link`, so they fall out naturally.
148
+ export function parseDdgHtml(html: string): DdgResult[] {
149
+ const results: DdgResult[] = []
150
+ const linkRegex = /<a\s+[^>]*href=(['"])([^'"]+)\1[^>]*class=(['"])result-link\3[^>]*>([\s\S]*?)<\/a>/g
151
+ for (const match of html.matchAll(linkRegex)) {
152
+ const url = decodeDdgUrl(match[2] ?? '')
153
+ const title = stripHtml(match[4] ?? '').trim()
154
+ if (!url || !title) continue
155
+
156
+ const blockEnd = match.index !== undefined ? match.index + 2000 : html.length
157
+ const blockStart = match.index !== undefined ? match.index : 0
158
+ const window = html.slice(blockStart, blockEnd)
159
+ const snippetMatch = /<td\s+[^>]*class=(['"])result-snippet\1[^>]*>([\s\S]*?)<\/td>/.exec(window)
160
+ const snippet = snippetMatch ? stripHtml(snippetMatch[2] ?? '').trim() : ''
161
+
162
+ results.push({ title, url, snippet })
163
+ }
164
+ return results
165
+ }
166
+
167
+ // DDG sometimes wraps result URLs in a redirect like
168
+ // //duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2F&rut=...
169
+ // Unwrap when present so the model sees the real destination.
170
+ function decodeDdgUrl(href: string): string {
171
+ if (!href) return ''
172
+ const normalized = href.startsWith('//') ? `https:${href}` : href
173
+ try {
174
+ const parsed = new URL(normalized)
175
+ if (parsed.hostname.endsWith('duckduckgo.com') && parsed.pathname === '/l/') {
176
+ const inner = parsed.searchParams.get('uddg')
177
+ if (inner) return inner
178
+ }
179
+ return parsed.toString()
180
+ } catch {
181
+ return href
182
+ }
183
+ }
184
+
185
+ function stripHtml(input: string): string {
186
+ return decodeEntities(input.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ')
187
+ }
188
+
189
+ const NAMED_ENTITIES: Record<string, string> = {
190
+ amp: '&',
191
+ lt: '<',
192
+ gt: '>',
193
+ quot: '"',
194
+ apos: "'",
195
+ nbsp: ' ',
196
+ mdash: '—',
197
+ ndash: '–',
198
+ hellip: '…',
199
+ laquo: '«',
200
+ raquo: '»',
201
+ copy: '©',
202
+ reg: '®',
203
+ trade: '™',
204
+ }
205
+
206
+ function decodeEntities(input: string): string {
207
+ return input.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body: string) => {
208
+ if (body.startsWith('#x') || body.startsWith('#X')) {
209
+ const code = parseInt(body.slice(2), 16)
210
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole
211
+ }
212
+ if (body.startsWith('#')) {
213
+ const code = parseInt(body.slice(1), 10)
214
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole
215
+ }
216
+ return NAMED_ENTITIES[body] ?? whole
217
+ })
218
+ }