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,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
|
+
}
|