typeclaw 0.13.0 → 0.15.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/package.json +2 -2
- package/src/agent/system-prompt.ts +11 -1
- package/src/agent/tools/skip-response.ts +24 -32
- package/src/agent/tools/spawn-subagent.ts +2 -0
- package/src/channels/adapters/discord-bot.ts +8 -1
- package/src/channels/adapters/github/inbound.ts +44 -5
- package/src/channels/adapters/github/index.ts +32 -0
- package/src/channels/adapters/kakaotalk-format.ts +239 -0
- package/src/channels/adapters/kakaotalk.ts +54 -5
- package/src/channels/adapters/telegram-bot.ts +11 -1
- package/src/channels/router.ts +152 -28
- package/src/channels/types.ts +22 -0
- package/src/config/providers.ts +17 -4
- package/src/container/start.ts +17 -0
- package/src/doctor/channel-checks.ts +328 -0
- package/src/doctor/checks.ts +2 -0
- package/src/init/dockerfile.ts +45 -8
- package/src/run/index.ts +18 -1
- package/src/sandbox/availability.ts +35 -0
- package/src/sandbox/build.ts +128 -0
- package/src/sandbox/errors.ts +20 -0
- package/src/sandbox/index.ts +14 -0
- package/src/sandbox/policy.ts +47 -0
- package/src/sandbox/quote.ts +18 -0
- package/src/secrets/claude-credentials-json.ts +129 -0
- package/src/secrets/export-claude-credentials-file.ts +279 -0
- package/src/secrets/index.ts +10 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +11 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
- package/typeclaw.schema.json +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@mariozechner/pi-coding-agent": "^0.67.3",
|
|
47
47
|
"@mariozechner/pi-tui": "^0.67.3",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"agent-messenger": "2.
|
|
49
|
+
"agent-messenger": "2.19.0",
|
|
50
50
|
"cheerio": "^1.2.0",
|
|
51
51
|
"citty": "^0.2.2",
|
|
52
52
|
"cron-parser": "^5.5.0",
|
|
@@ -12,7 +12,17 @@ TypeClaw is domain-agnostic — your purpose is defined by \`IDENTITY.md\`, your
|
|
|
12
12
|
- **AGENTS.md** *(read on demand)* — your operating manual. Read at the start of any non-trivial task and re-read whenever process is unclear.
|
|
13
13
|
- **\`memory/topics/\`** *(always injected below, READ-ONLY)* — sharded long-term memory, owned by the dreaming subagent. To capture something memorable, surface it in your reply or let the memory-logger append to \`memory/streams/\`; never edit memory shards directly.
|
|
14
14
|
|
|
15
|
-
If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never memory shards.
|
|
15
|
+
If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never memory shards. **Use this routing when you have something durable to record:**
|
|
16
|
+
|
|
17
|
+
- *role, function, scope of work, who you are to this user* → IDENTITY.md
|
|
18
|
+
- *voice, tone, register, language preferences, persona quirks* → SOUL.md
|
|
19
|
+
- *facts about the user (name, timezone, projects, preferences they hold across tasks)* → USER.md
|
|
20
|
+
- *working conventions, repeatable procedures, "always do X" rules, things future-you needs to read before acting* → AGENTS.md
|
|
21
|
+
- *one-off context for this conversation only* → don't write a file; it'll be captured in \`memory/streams/\` automatically
|
|
22
|
+
|
|
23
|
+
When in doubt between SOUL.md and AGENTS.md: if it describes *how you sound*, it's SOUL; if it describes *how you work*, it's AGENTS. Tone preferences ("be more terse") go to SOUL.md; process rules ("always run tests before committing") go to AGENTS.md.
|
|
24
|
+
|
|
25
|
+
**Edit discipline.** Prefer rewriting in place to growing files. SOUL.md should stay short — a paragraph or two; if it's drifting past a screen, you're using it as a scratchpad and the model that reads it will start ignoring the back half. IDENTITY.md is similar — a few lines of who you are, not a résumé. AGENTS.md is the one allowed to grow. Don't rewrite SOUL.md on the first piece of tone feedback in a session — wait until the user repeats a preference or asks you directly to update it; a single off-day request isn't a durable change.
|
|
16
26
|
|
|
17
27
|
## Your workspace
|
|
18
28
|
|
|
@@ -39,10 +39,13 @@ export type SkipResponseDetails = {
|
|
|
39
39
|
// `skip_response` is preferred whenever the model has a reason worth
|
|
40
40
|
// recording. See session-origin.ts for the prompt-level decision rule.
|
|
41
41
|
//
|
|
42
|
-
// Order-dependence with `channel_reply`/`channel_send
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
42
|
+
// Order-dependence with `channel_reply`/`channel_send` is asymmetric:
|
|
43
|
+
// - skip BEFORE any send → commits to silence; the router rejects any
|
|
44
|
+
// subsequent tool-source send this turn with `SKIP_RESPONSE_LOCK_ERROR`.
|
|
45
|
+
// - skip AFTER a send → accepted as a terminal no-op (`recorded-after-send`).
|
|
46
|
+
// The earlier reply stands; this posts nothing and ends the turn. Rejecting
|
|
47
|
+
// it (the old behavior) drove a livelock: denied a clean silent exit, the
|
|
48
|
+
// model re-sent, got re-denied on the next skip, and repeated to the cap.
|
|
46
49
|
export function createSkipResponseTool({
|
|
47
50
|
router,
|
|
48
51
|
sessionId,
|
|
@@ -55,12 +58,14 @@ export function createSkipResponseTool({
|
|
|
55
58
|
'Decline to send a user-facing reply this turn, with a logged reason. Use this ' +
|
|
56
59
|
'instead of narrating "I have nothing to add" / "I will stay quiet" in your visible ' +
|
|
57
60
|
'response. The reason is written to host logs (visible via `typeclaw logs -f`) but ' +
|
|
58
|
-
'never delivered to the user.
|
|
59
|
-
'`channel_reply` / `channel_send` in the same
|
|
60
|
-
'
|
|
61
|
-
'
|
|
62
|
-
'
|
|
63
|
-
'this
|
|
61
|
+
'never delivered to the user. If you call this BEFORE sending anything this turn, it ' +
|
|
62
|
+
'commits you to silence and any later `channel_reply` / `channel_send` in the same ' +
|
|
63
|
+
'turn is rejected. If you call it AFTER a reply has already landed this turn (e.g. you ' +
|
|
64
|
+
'posted an ack and now want to wait quietly for a backgrounded subagent), it is ' +
|
|
65
|
+
'accepted as a terminal no-op: your earlier reply stands, nothing further is sent, and ' +
|
|
66
|
+
'your turn ends. Either way, call this as your terminal tool when you decide to stop ' +
|
|
67
|
+
'talking — do NOT keep sending "still working" updates. Prefer this over the ' +
|
|
68
|
+
'`NO_REPLY` text sentinel whenever you have a reason worth recording.',
|
|
64
69
|
parameters: Type.Object({
|
|
65
70
|
reason: Type.String({
|
|
66
71
|
description:
|
|
@@ -85,33 +90,20 @@ export function createSkipResponseTool({
|
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
const result = router.markTurnSkipped({ parentSessionId: sessionId, reason })
|
|
88
|
-
if (result.kind === '
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
// Surface a clear error and refuse to stamp the flag so the rest of
|
|
94
|
-
// the turn behaves as a normal reply turn.
|
|
95
|
-
logger.warn(
|
|
96
|
-
formatChannelToolFailure(
|
|
97
|
-
'skip_response',
|
|
98
|
-
`channel send already happened this turn (reason=${JSON.stringify(reason)})`,
|
|
99
|
-
),
|
|
100
|
-
)
|
|
101
|
-
const details: SkipResponseDetails = {
|
|
102
|
-
ok: false,
|
|
103
|
-
suppressed: false,
|
|
104
|
-
reason,
|
|
105
|
-
error: 'send-already-happened',
|
|
106
|
-
}
|
|
93
|
+
if (result.kind === 'recorded-after-send') {
|
|
94
|
+
// Reply-first skip: an ack already landed; this just ends the turn
|
|
95
|
+
// quietly. Not suppressed (the reply stands) and not an error (erroring
|
|
96
|
+
// here is what drove the historical re-send livelock). Router logged it.
|
|
97
|
+
const details: SkipResponseDetails = { ok: true, suppressed: false, reason }
|
|
107
98
|
return {
|
|
108
99
|
content: [
|
|
109
100
|
{
|
|
110
101
|
type: 'text' as const,
|
|
111
102
|
text:
|
|
112
|
-
'skip_response
|
|
113
|
-
'
|
|
114
|
-
'
|
|
103
|
+
'skip_response accepted: your earlier channel reply this turn stands, and ' +
|
|
104
|
+
'no further message will be sent. End your turn now — do not send "still ' +
|
|
105
|
+
'working" updates while a backgrounded subagent runs; the completion ' +
|
|
106
|
+
'reminder will wake you when it finishes.',
|
|
115
107
|
},
|
|
116
108
|
],
|
|
117
109
|
details,
|
|
@@ -103,6 +103,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
103
103
|
if (params.description !== undefined) payload.description = params.description
|
|
104
104
|
|
|
105
105
|
const startedAt = now()
|
|
106
|
+
const spawnedByRole = permissions?.resolveRole(origin)
|
|
106
107
|
const { handle, completion } = startSubagent(subagentName, {
|
|
107
108
|
registry,
|
|
108
109
|
createSessionForSubagent,
|
|
@@ -110,6 +111,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
110
111
|
userPrompt: params.prompt,
|
|
111
112
|
payload: subagent.payloadSchema ? payload : undefined,
|
|
112
113
|
parentSessionId,
|
|
114
|
+
...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
|
|
113
115
|
...(origin !== undefined ? { spawnedByOrigin: origin } : {}),
|
|
114
116
|
taskId,
|
|
115
117
|
})
|
|
@@ -393,7 +393,14 @@ export function createOutboundCallback(deps: {
|
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
try {
|
|
396
|
-
const
|
|
396
|
+
const sendOptions: { thread_id?: string; reply_to?: string } = {}
|
|
397
|
+
if (msg.thread) sendOptions.thread_id = msg.thread
|
|
398
|
+
if (msg.replyTo?.externalMessageId) sendOptions.reply_to = msg.replyTo.externalMessageId
|
|
399
|
+
const sent = await client.sendMessage(
|
|
400
|
+
msg.chat,
|
|
401
|
+
text,
|
|
402
|
+
Object.keys(sendOptions).length > 0 ? sendOptions : undefined,
|
|
403
|
+
)
|
|
397
404
|
logger.info(`[discord-bot] sent id=${sent.id} ${tag}`)
|
|
398
405
|
return { ok: true }
|
|
399
406
|
} catch (err) {
|
|
@@ -45,7 +45,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
45
45
|
|
|
46
46
|
const selfId = options.selfId()
|
|
47
47
|
const selfLogin = options.selfLogin()
|
|
48
|
-
const author = readAuthor(payload)
|
|
48
|
+
const author = readAuthor(event, payload)
|
|
49
49
|
if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
|
|
50
50
|
options.logger.info(
|
|
51
51
|
`[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
|
|
@@ -363,13 +363,52 @@ function readRepository(payload: Record<string, unknown>): { owner: string; name
|
|
|
363
363
|
return { owner: ownerLogin, name }
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
function readAuthor(payload: Record<string, unknown>): GithubUser | null {
|
|
367
|
-
const
|
|
368
|
-
for (const candidate of candidates) {
|
|
366
|
+
function readAuthor(event: string, payload: Record<string, unknown>): GithubUser | null {
|
|
367
|
+
for (const candidate of eventAuthorCandidates(event, payload)) {
|
|
369
368
|
const user = readUser(readRecord(candidate)?.user)
|
|
370
369
|
if (user !== null) return user
|
|
371
370
|
}
|
|
372
|
-
|
|
371
|
+
// Every GitHub webhook payload carries `sender` — the actor who triggered the
|
|
372
|
+
// delivery. It is the universal fallback so events not enumerated above (and
|
|
373
|
+
// any future ones the user adds to eventAllowlist) still drop self-authored
|
|
374
|
+
// deliveries instead of slipping past the guard.
|
|
375
|
+
return readUser(payload.sender)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Maps each event to the entity whose `user` is the true author of THIS event,
|
|
379
|
+
// listed before broader containers. A pull_request_review payload ships both
|
|
380
|
+
// `pull_request` (the PR author) and `review` (the reviewer); the self-author
|
|
381
|
+
// drop must see the reviewer, so `review` must come first. PR #455's flat order
|
|
382
|
+
// (`pull_request` before `review`) made a self-review on someone else's PR
|
|
383
|
+
// resolve to the PR author, slip past the drop, and loop (see PR #460).
|
|
384
|
+
//
|
|
385
|
+
// `pull_request` and `pull_request_review_thread` carry only the `pull_request`
|
|
386
|
+
// container, whose `user` is the PR OPENER — not the actor of this delivery.
|
|
387
|
+
// For these events the self-author question is "who triggered the action?"
|
|
388
|
+
// (review_requested, edited, reopened, resolved, …), which is always
|
|
389
|
+
// `payload.sender`, never the opener. Mapping them to `[]` makes readAuthor
|
|
390
|
+
// skip the opener and fall through to the `sender` fallback. PR #462's
|
|
391
|
+
// `['pull_request']` resolved to the opener, so a human action on a
|
|
392
|
+
// bot-opened PR matched the bot and was wrongly dropped (the inbound landed
|
|
393
|
+
// as awareness-only "Recent context" and the agent never replied).
|
|
394
|
+
const PRIMARY_AUTHOR_KEYS: Record<string, readonly string[]> = {
|
|
395
|
+
issue_comment: ['comment'],
|
|
396
|
+
pull_request_review_comment: ['comment'],
|
|
397
|
+
discussion_comment: ['comment'],
|
|
398
|
+
commit_comment: ['comment'],
|
|
399
|
+
pull_request_review: ['review'],
|
|
400
|
+
pull_request_review_thread: [],
|
|
401
|
+
issues: ['issue'],
|
|
402
|
+
pull_request: [],
|
|
403
|
+
discussion: ['discussion'],
|
|
404
|
+
release: ['release'],
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const FALLBACK_AUTHOR_KEYS = ['comment', 'review', 'issue', 'pull_request', 'discussion', 'release'] as const
|
|
408
|
+
|
|
409
|
+
function eventAuthorCandidates(event: string, payload: Record<string, unknown>): unknown[] {
|
|
410
|
+
const keys = PRIMARY_AUTHOR_KEYS[event] ?? FALLBACK_AUTHOR_KEYS
|
|
411
|
+
return keys.map((key) => payload[key])
|
|
373
412
|
}
|
|
374
413
|
|
|
375
414
|
// Matches by id OR login. Issue #452 captured a self-responding loop where
|
|
@@ -53,6 +53,14 @@ export type GithubAdapterOptions = {
|
|
|
53
53
|
// Test-only: replaces the wall-clock sleep used for the registration
|
|
54
54
|
// delay above. Production leaves it undefined and we use `setTimeout`.
|
|
55
55
|
sleep?: (ms: number) => Promise<void>
|
|
56
|
+
// How often to proactively refresh the token and update GH_TOKEN
|
|
57
|
+
// when the adapter is running but has not made an outbound API call
|
|
58
|
+
// recently. Zero disables the background refresh entirely.
|
|
59
|
+
// Default: 30 minutes.
|
|
60
|
+
tokenRefreshIntervalMs?: number
|
|
61
|
+
// Test-only: replaces `setInterval` so tests can control when the
|
|
62
|
+
// background refresh fires without waiting on real wall-clock time.
|
|
63
|
+
setInterval?: (handler: () => void, ms: number) => { clear: () => void }
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
export type GithubAdapter = {
|
|
@@ -68,6 +76,7 @@ const consoleLogger: GithubAdapterLogger = {
|
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
const DEFAULT_WEBHOOK_REGISTRATION_DELAY_MS = 2_000
|
|
79
|
+
const DEFAULT_TOKEN_REFRESH_INTERVAL_MS = 30 * 60 * 1000
|
|
71
80
|
|
|
72
81
|
export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapter {
|
|
73
82
|
const logger = options.logger ?? consoleLogger
|
|
@@ -83,6 +92,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
83
92
|
let selfLogin: string | null = null
|
|
84
93
|
let started = false
|
|
85
94
|
let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
|
|
95
|
+
let tokenRefreshTimer: { clear: () => void } | null = null
|
|
86
96
|
const workspaceByChat = new Map<string, string>()
|
|
87
97
|
|
|
88
98
|
const rememberWorkspace = (workspace: string, chat: string): void => {
|
|
@@ -168,6 +178,24 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
168
178
|
// automatically when within 5 minutes of expiry.
|
|
169
179
|
process.env.GH_TOKEN = await auth.token()
|
|
170
180
|
started = true
|
|
181
|
+
// Keep GH_TOKEN warm even when the adapter is only receiving inbound
|
|
182
|
+
// webhooks and not making outbound API calls. This prevents `gh` CLI
|
|
183
|
+
// calls from the agent from failing with 401 after the token expires.
|
|
184
|
+
const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
|
|
185
|
+
if (tokenRefreshIntervalMs > 0) {
|
|
186
|
+
const refresh = () => {
|
|
187
|
+
tokenFn().catch((err) => {
|
|
188
|
+
logger.error(`[github] periodic token refresh failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
const setIntervalFn =
|
|
192
|
+
options.setInterval ??
|
|
193
|
+
((handler: () => void, ms: number) => {
|
|
194
|
+
const timer = setInterval(handler, ms)
|
|
195
|
+
return { clear: () => clearInterval(timer) }
|
|
196
|
+
})
|
|
197
|
+
tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
|
|
198
|
+
}
|
|
171
199
|
logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
|
|
172
200
|
// Best-effort: App-only preflight that compares the installation's granted
|
|
173
201
|
// permissions against the configured eventAllowlist and warns about gaps.
|
|
@@ -241,6 +269,10 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
241
269
|
logDeregistrationOutcome(logger, deregistration)
|
|
242
270
|
managedHooks = []
|
|
243
271
|
}
|
|
272
|
+
if (tokenRefreshTimer !== null) {
|
|
273
|
+
tokenRefreshTimer.clear()
|
|
274
|
+
tokenRefreshTimer = null
|
|
275
|
+
}
|
|
244
276
|
await auth.dispose()
|
|
245
277
|
delete process.env.GH_TOKEN
|
|
246
278
|
server = null
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// KakaoTalk's LOCO protocol renders no rich text — bytes display verbatim, so
|
|
2
|
+
// the agent's Markdown (`**bold**`, `### heading`, fenced ```blocks```) leaks
|
|
3
|
+
// literal `*`/`#`/backtick noise into the chat. This strips the formatting
|
|
4
|
+
// markers and keeps the content. Mirrors telegram-bot-format.ts, but emits
|
|
5
|
+
// plain content instead of re-encoding to MarkdownV2. Links collapse to
|
|
6
|
+
// `label (url)` so the destination survives; list/quote markers stay (they
|
|
7
|
+
// read fine unrendered).
|
|
8
|
+
|
|
9
|
+
export function toKakaoPlainText(input: string): string {
|
|
10
|
+
// Pull fenced code out first so a `*` inside a block is not re-tokenized as
|
|
11
|
+
// italic.
|
|
12
|
+
const out: string[] = []
|
|
13
|
+
let i = 0
|
|
14
|
+
while (i < input.length) {
|
|
15
|
+
if (matchesAt(input, i, '```')) {
|
|
16
|
+
const fenceEnd = findFenceEnd(input, i + 3)
|
|
17
|
+
if (fenceEnd !== -1) {
|
|
18
|
+
out.push(renderFence(input.slice(i + 3, fenceEnd)))
|
|
19
|
+
i = fenceEnd + 3
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
// Unterminated fence — strip the open backticks and render the rest
|
|
23
|
+
// inline so we never infinite-loop and never drop the tail.
|
|
24
|
+
out.push(renderInline(stripLeadingFence(input.slice(i + 3))))
|
|
25
|
+
break
|
|
26
|
+
}
|
|
27
|
+
const nextFence = input.indexOf('```', i)
|
|
28
|
+
const segmentEnd = nextFence === -1 ? input.length : nextFence
|
|
29
|
+
out.push(renderLines(input.slice(i, segmentEnd)))
|
|
30
|
+
i = segmentEnd
|
|
31
|
+
}
|
|
32
|
+
return out.join('')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function matchesAt(s: string, idx: number, needle: string): boolean {
|
|
36
|
+
return s.slice(idx, idx + needle.length) === needle
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findFenceEnd(s: string, start: number): number {
|
|
40
|
+
return s.indexOf('```', start)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripLeadingFence(inner: string): string {
|
|
44
|
+
// Drop an optional language hint and the newline after an opening fence.
|
|
45
|
+
const newline = inner.indexOf('\n')
|
|
46
|
+
if (newline === -1) return inner
|
|
47
|
+
const candidate = inner.slice(0, newline).trim()
|
|
48
|
+
if (candidate === '' || /^[A-Za-z0-9_+\-.]+$/.test(candidate)) {
|
|
49
|
+
return inner.slice(newline + 1)
|
|
50
|
+
}
|
|
51
|
+
return inner
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderFence(inner: string): string {
|
|
55
|
+
// Keep the code body verbatim, drop the fences and any language hint.
|
|
56
|
+
let body = inner
|
|
57
|
+
const newline = inner.indexOf('\n')
|
|
58
|
+
if (newline !== -1) {
|
|
59
|
+
const candidate = inner.slice(0, newline).trim()
|
|
60
|
+
if (candidate === '' || /^[A-Za-z0-9_+\-.]+$/.test(candidate)) {
|
|
61
|
+
body = inner.slice(newline + 1)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (body.endsWith('\n')) body = body.slice(0, -1)
|
|
65
|
+
return body
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Strip per-line block markers (heading hashes, blockquote arrows) before
|
|
69
|
+
// running the inline tokenizer on each line. List markers (`- `, `* `, `1.`)
|
|
70
|
+
// are left intact — they read fine as plain text and signal structure.
|
|
71
|
+
function renderLines(text: string): string {
|
|
72
|
+
const lines = text.split('\n')
|
|
73
|
+
const rendered = lines.map((line) => renderInline(stripBlockMarkers(line)))
|
|
74
|
+
return rendered.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stripBlockMarkers(line: string): string {
|
|
78
|
+
// `### heading` → `heading`; `> quote` → `quote`. Only acts on leading
|
|
79
|
+
// markers after optional indentation so mid-line `#`/`>` stay literal.
|
|
80
|
+
const heading = /^(\s*)#{1,6}\s+(.*)$/.exec(line)
|
|
81
|
+
if (heading !== null) return heading[1]! + heading[2]!
|
|
82
|
+
const quote = /^(\s*)>\s?(.*)$/.exec(line)
|
|
83
|
+
if (quote !== null) return quote[1]! + quote[2]!
|
|
84
|
+
return line
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Inline tokenizer. Recognizes (in priority order):
|
|
88
|
+
// 1. Inline code: `code` → code
|
|
89
|
+
// 2. Links: [text](url) → text (url)
|
|
90
|
+
// 3. Bold: **text** / __text__ → text
|
|
91
|
+
// 4. Strikethrough: ~~text~~ → text
|
|
92
|
+
// 5. Italic: *text* / _text_ → text
|
|
93
|
+
//
|
|
94
|
+
// Bold is checked before italic so `**` is not eaten as two italic markers.
|
|
95
|
+
// Word-boundary guards keep snake_case identifiers and `a*b` math from being
|
|
96
|
+
// mistaken for emphasis — the same rules the Telegram formatter uses.
|
|
97
|
+
function renderInline(text: string): string {
|
|
98
|
+
const out: string[] = []
|
|
99
|
+
let i = 0
|
|
100
|
+
while (i < text.length) {
|
|
101
|
+
const ch = text[i]!
|
|
102
|
+
|
|
103
|
+
if (ch === '`') {
|
|
104
|
+
const close = text.indexOf('`', i + 1)
|
|
105
|
+
if (close !== -1) {
|
|
106
|
+
out.push(text.slice(i + 1, close))
|
|
107
|
+
i = close + 1
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (ch === '[') {
|
|
113
|
+
const link = parseLink(text, i)
|
|
114
|
+
if (link !== null) {
|
|
115
|
+
const label = renderInline(link.label)
|
|
116
|
+
out.push(link.url === '' ? label : `${label} (${link.url})`)
|
|
117
|
+
i = link.end
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (ch === '*' && text[i + 1] === '*') {
|
|
123
|
+
const close = findClose(text, i + 2, '**')
|
|
124
|
+
if (close !== -1 && close > i + 2) {
|
|
125
|
+
out.push(renderInline(text.slice(i + 2, close)))
|
|
126
|
+
i = close + 2
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (ch === '_' && text[i + 1] === '_' && !isWordChar(text[i - 1])) {
|
|
131
|
+
const close = findClose(text, i + 2, '__')
|
|
132
|
+
if (close !== -1 && close > i + 2 && !isWordChar(text[close + 2])) {
|
|
133
|
+
out.push(renderInline(text.slice(i + 2, close)))
|
|
134
|
+
i = close + 2
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (ch === '~' && text[i + 1] === '~') {
|
|
140
|
+
const close = findClose(text, i + 2, '~~')
|
|
141
|
+
if (close !== -1 && close > i + 2) {
|
|
142
|
+
out.push(renderInline(text.slice(i + 2, close)))
|
|
143
|
+
i = close + 2
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (ch === '*' && !isWordChar(text[i - 1])) {
|
|
149
|
+
const close = findInlineClose(text, i + 1, '*')
|
|
150
|
+
if (close !== -1 && !isWordChar(text[close + 1])) {
|
|
151
|
+
const inner = text.slice(i + 1, close)
|
|
152
|
+
if (inner !== '' && !/^\s|\s$/.test(inner)) {
|
|
153
|
+
out.push(renderInline(inner))
|
|
154
|
+
i = close + 1
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (ch === '_' && !isWordChar(text[i - 1])) {
|
|
160
|
+
const close = findInlineClose(text, i + 1, '_')
|
|
161
|
+
if (close !== -1 && !isWordChar(text[close + 1])) {
|
|
162
|
+
const inner = text.slice(i + 1, close)
|
|
163
|
+
if (inner !== '' && !/^\s|\s$/.test(inner)) {
|
|
164
|
+
out.push(renderInline(inner))
|
|
165
|
+
i = close + 1
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
out.push(ch)
|
|
172
|
+
i++
|
|
173
|
+
}
|
|
174
|
+
return out.join('')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function findClose(text: string, from: number, marker: string): number {
|
|
178
|
+
let i = from
|
|
179
|
+
while (i <= text.length - marker.length) {
|
|
180
|
+
if (text[i] === '\\') {
|
|
181
|
+
i += 2
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (matchesAt(text, i, marker)) return i
|
|
185
|
+
i++
|
|
186
|
+
}
|
|
187
|
+
return -1
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function findInlineClose(text: string, from: number, marker: string): number {
|
|
191
|
+
let i = from
|
|
192
|
+
while (i < text.length) {
|
|
193
|
+
if (text[i] === '\n') return -1
|
|
194
|
+
if (text[i] === '\\') {
|
|
195
|
+
i += 2
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (matchesAt(text, i, marker)) return i
|
|
199
|
+
i++
|
|
200
|
+
}
|
|
201
|
+
return -1
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseLink(text: string, start: number): { label: string; url: string; end: number } | null {
|
|
205
|
+
let i = start + 1
|
|
206
|
+
const labelStart = i
|
|
207
|
+
while (i < text.length) {
|
|
208
|
+
const c = text[i]!
|
|
209
|
+
if (c === '\\') {
|
|
210
|
+
i += 2
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (c === ']') break
|
|
214
|
+
if (c === '\n') return null
|
|
215
|
+
i++
|
|
216
|
+
}
|
|
217
|
+
if (text[i] !== ']' || text[i + 1] !== '(') return null
|
|
218
|
+
const label = text.slice(labelStart, i)
|
|
219
|
+
const urlStart = i + 2
|
|
220
|
+
let j = urlStart
|
|
221
|
+
while (j < text.length) {
|
|
222
|
+
const c = text[j]!
|
|
223
|
+
if (c === '\\') {
|
|
224
|
+
j += 2
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
if (c === ')') break
|
|
228
|
+
if (c === '(') return null
|
|
229
|
+
if (c === '\n') return null
|
|
230
|
+
j++
|
|
231
|
+
}
|
|
232
|
+
if (text[j] !== ')') return null
|
|
233
|
+
return { label, url: text.slice(urlStart, j), end: j + 1 }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isWordChar(ch: string | undefined): boolean {
|
|
237
|
+
if (ch === undefined) return false
|
|
238
|
+
return /[A-Za-z0-9_]/.test(ch)
|
|
239
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type KakaoMember,
|
|
9
9
|
type KakaoMessage,
|
|
10
10
|
type KakaoProfile,
|
|
11
|
+
type KakaoReplyTarget,
|
|
11
12
|
type KakaoSendResult,
|
|
12
13
|
type KakaoTalkListenerEventMap,
|
|
13
14
|
type KakaoTalkPushEmoticonEvent,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
} from 'agent-messenger/kakaotalk'
|
|
16
17
|
import type { KakaoAccountCredentials, KakaoConfig, PendingLoginState } from 'agent-messenger/kakaotalk'
|
|
17
18
|
|
|
18
|
-
import type
|
|
19
|
+
import { prependQuoteAnchor, type ChannelRouter } from '@/channels/router'
|
|
19
20
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
20
21
|
import type {
|
|
21
22
|
ChannelHistoryMessage,
|
|
@@ -39,6 +40,7 @@ import { createKakaoAuthorResolver, type KakaoAuthorResolver } from './kakaotalk
|
|
|
39
40
|
import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaotalk-channel-resolver'
|
|
40
41
|
import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
|
|
41
42
|
import { createFetchAttachmentCallback } from './kakaotalk-fetch-attachment'
|
|
43
|
+
import { toKakaoPlainText } from './kakaotalk-format'
|
|
42
44
|
|
|
43
45
|
// Structural duck-type of the upstream KakaoTalkClient class. The upstream
|
|
44
46
|
// type is a class with private fields, and TypeScript treats those
|
|
@@ -53,7 +55,7 @@ export interface KakaoTalkClient {
|
|
|
53
55
|
): Promise<this>
|
|
54
56
|
getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]>
|
|
55
57
|
getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]>
|
|
56
|
-
sendMessage(chatId: string, text: string): Promise<KakaoSendResult>
|
|
58
|
+
sendMessage(chatId: string, text: string, options?: { replyTo?: KakaoReplyTarget }): Promise<KakaoSendResult>
|
|
57
59
|
sendAttachment(
|
|
58
60
|
chatId: string,
|
|
59
61
|
data: Uint8Array | Buffer,
|
|
@@ -160,6 +162,11 @@ export type KakaotalkAdapter = {
|
|
|
160
162
|
|
|
161
163
|
export const KAKAO_HISTORY_LIMIT_MAX = 200
|
|
162
164
|
|
|
165
|
+
// How far back to scan for a reply target's source message. Matches the upstream
|
|
166
|
+
// CLI's window; an anchored reply targets the message just answered, so the
|
|
167
|
+
// target is almost always near the head of this window.
|
|
168
|
+
const KAKAO_REPLY_LOOKUP_COUNT = 100
|
|
169
|
+
|
|
163
170
|
function formatLabel(name: string | undefined, id: string, prefix = ''): string {
|
|
164
171
|
if (name === undefined || name === '' || name === id) return id
|
|
165
172
|
return `${prefix}${name}(${id})`
|
|
@@ -171,7 +178,7 @@ async function readAttachmentBuffer(path: string): Promise<Buffer> {
|
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
export function createOutboundCallback(deps: {
|
|
174
|
-
client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment'>
|
|
181
|
+
client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment' | 'getMessages'>
|
|
175
182
|
logger: KakaotalkAdapterLogger
|
|
176
183
|
formatChannelTag: (workspace: string, chat: string) => Promise<string>
|
|
177
184
|
readFile?: (path: string) => Promise<Buffer>
|
|
@@ -182,7 +189,7 @@ export function createOutboundCallback(deps: {
|
|
|
182
189
|
if (msg.adapter !== 'kakaotalk') {
|
|
183
190
|
return { ok: false, error: `unknown adapter: ${msg.adapter}` }
|
|
184
191
|
}
|
|
185
|
-
const text = msg.text ?? ''
|
|
192
|
+
const text = toKakaoPlainText(msg.text ?? '')
|
|
186
193
|
const attachments = msg.attachments ?? []
|
|
187
194
|
if (text === '' && attachments.length === 0) {
|
|
188
195
|
return { ok: false, error: 'message has neither text nor attachments' }
|
|
@@ -221,8 +228,26 @@ export function createOutboundCallback(deps: {
|
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
if (text !== '') {
|
|
231
|
+
// KakaoTalk's native reply payload is built from the *source* message
|
|
232
|
+
// (author, original text, type), which the SDK does not derive from a
|
|
233
|
+
// bare log_id — we resolve it from recent history. If that lookup can't
|
|
234
|
+
// find the target (scrolled past the window, or the fetch failed), we
|
|
235
|
+
// degrade to the same blockquote anchor the router uses for quote-mode
|
|
236
|
+
// adapters, so the reply still visibly references the right message.
|
|
237
|
+
let outboundText = text
|
|
238
|
+
let replyTarget: KakaoReplyTarget | undefined
|
|
239
|
+
if (msg.replyTo !== undefined) {
|
|
240
|
+
replyTarget = await resolveKakaoReplyTarget(client, msg.chat, msg.replyTo.externalMessageId, logger)
|
|
241
|
+
if (replyTarget === undefined && msg.replyTo.source !== undefined) {
|
|
242
|
+
outboundText = prependQuoteAnchor(text, msg.replyTo.source)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
224
245
|
try {
|
|
225
|
-
const result = await client.sendMessage(
|
|
246
|
+
const result = await client.sendMessage(
|
|
247
|
+
msg.chat,
|
|
248
|
+
outboundText,
|
|
249
|
+
replyTarget !== undefined ? { replyTo: replyTarget } : undefined,
|
|
250
|
+
)
|
|
226
251
|
if (!result.success) {
|
|
227
252
|
logger.error(`[kakaotalk] sendMessage status_code=${result.status_code} ${tag}`)
|
|
228
253
|
return { ok: false, error: `kakaotalk send failed with status ${result.status_code}` }
|
|
@@ -239,6 +264,30 @@ export function createOutboundCallback(deps: {
|
|
|
239
264
|
}
|
|
240
265
|
}
|
|
241
266
|
|
|
267
|
+
// KakaoTalk replies need the full source message, not just its log_id. Resolve
|
|
268
|
+
// it from the chat's recent history (matching the upstream CLI's approach).
|
|
269
|
+
// Returns undefined when the target isn't in the fetched window or the fetch
|
|
270
|
+
// throws — the caller degrades to the blockquote fallback in that case.
|
|
271
|
+
async function resolveKakaoReplyTarget(
|
|
272
|
+
client: Pick<KakaoTalkClient, 'getMessages'>,
|
|
273
|
+
chatId: string,
|
|
274
|
+
externalMessageId: string,
|
|
275
|
+
logger: KakaotalkAdapterLogger,
|
|
276
|
+
): Promise<KakaoReplyTarget | undefined> {
|
|
277
|
+
try {
|
|
278
|
+
const messages = await client.getMessages(chatId, { count: KAKAO_REPLY_LOOKUP_COUNT })
|
|
279
|
+
const target = messages.find((m) => m.log_id === externalMessageId)
|
|
280
|
+
if (target === undefined) {
|
|
281
|
+
logger.warn(`[kakaotalk] reply target log_id=${externalMessageId} not in last ${KAKAO_REPLY_LOOKUP_COUNT}`)
|
|
282
|
+
return undefined
|
|
283
|
+
}
|
|
284
|
+
return { log_id: target.log_id, author_id: target.author_id, message: target.message, type: target.type }
|
|
285
|
+
} catch (err) {
|
|
286
|
+
logger.warn(`[kakaotalk] reply target lookup failed: ${describe(err)}`)
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
242
291
|
export function createKakaoHistoryCallback(deps: {
|
|
243
292
|
client: Pick<KakaoTalkClient, 'getMessages'>
|
|
244
293
|
logger: KakaotalkAdapterLogger
|