typeclaw 0.12.0 → 0.14.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 +1 -1
- package/scripts/dump-system-prompt.ts +12 -11
- package/src/agent/index.ts +15 -22
- package/src/agent/loop-guard.ts +170 -0
- package/src/agent/model-fallback.ts +2 -1
- package/src/agent/multimodal/index.ts +1 -1
- package/src/agent/multimodal/look-at.ts +118 -55
- package/src/agent/plugin-tools.ts +57 -0
- package/src/agent/subagents.ts +2 -1
- package/src/agent/system-prompt.ts +39 -26
- package/src/agent/tools/channel-fetch-attachment.ts +45 -16
- package/src/agent/tools/normalize-ref.ts +11 -0
- package/src/agent/tools/skip-response.ts +24 -32
- package/src/agent/tools/spawn-subagent.ts +2 -0
- package/src/bundled-plugins/reviewer/index.ts +11 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
- package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
- package/src/channels/adapters/discord-bot-classify.ts +32 -24
- package/src/channels/adapters/github/inbound.ts +63 -7
- package/src/channels/adapters/github/index.ts +32 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
- package/src/channels/adapters/kakaotalk-classify.ts +8 -1
- package/src/channels/adapters/kakaotalk.ts +19 -11
- package/src/channels/adapters/slack-bot-classify.ts +30 -14
- package/src/channels/adapters/slack-bot.ts +3 -2
- package/src/channels/adapters/telegram-bot-classify.ts +36 -13
- package/src/channels/adapters/telegram-bot.ts +3 -3
- package/src/channels/outbound-flood-filter.ts +57 -0
- package/src/channels/router.ts +114 -15
- package/src/channels/types.ts +52 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/mount.ts +157 -0
- package/src/cli/update.ts +6 -4
- package/src/config/mounts-mutation.ts +161 -0
- package/src/doctor/channel-checks.ts +328 -0
- package/src/doctor/checks.ts +2 -0
- package/src/init/dockerfile.ts +24 -7
- package/src/init/hatching.ts +1 -1
- package/src/plugin/index.ts +6 -0
- package/src/plugin/load-skill.ts +99 -0
- package/src/run/bundled-plugins.ts +2 -0
- package/src/run/index.ts +31 -1
- package/src/secrets/claude-credentials-json.ts +129 -0
- package/src/secrets/codex-auth-json.ts +67 -0
- package/src/secrets/export-claude-credentials-file.ts +279 -0
- package/src/secrets/export-codex-auth-file.ts +243 -0
- package/src/secrets/index.ts +16 -0
- package/src/server/command-runner.ts +2 -1
- package/src/server/index.ts +3 -2
- package/src/shared/index.ts +7 -1
- package/src/shared/local-time.ts +32 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
- package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
- package/src/update/index.ts +95 -26
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type OutboundFloodCheckResult = { ok: true } | { ok: false; reason: string }
|
|
2
|
+
|
|
3
|
+
const MIN_LENGTH = 40
|
|
4
|
+
const MAX_RUN = 30
|
|
5
|
+
const MIN_LONG_LENGTH = 80
|
|
6
|
+
const MIN_UNIQUE_RATIO = 0.05
|
|
7
|
+
const MAX_DOMINANCE = 0.9
|
|
8
|
+
|
|
9
|
+
export function checkOutboundFlood(text: string): OutboundFloodCheckResult {
|
|
10
|
+
if (text.length < MIN_LENGTH) return { ok: true }
|
|
11
|
+
|
|
12
|
+
const graphemes = Array.from(text.normalize('NFKC'))
|
|
13
|
+
if (graphemes.length < MIN_LENGTH) return { ok: true }
|
|
14
|
+
|
|
15
|
+
const longestRun = findLongestRun(graphemes)
|
|
16
|
+
if (longestRun >= MAX_RUN) return { ok: false, reason: `repeated-char-run:${longestRun}` }
|
|
17
|
+
|
|
18
|
+
if (graphemes.length < MIN_LONG_LENGTH) return { ok: true }
|
|
19
|
+
|
|
20
|
+
const counts = countGraphemes(graphemes)
|
|
21
|
+
const uniqueRatio = counts.size / graphemes.length
|
|
22
|
+
if (uniqueRatio < MIN_UNIQUE_RATIO) return { ok: false, reason: `low-unique-ratio:${uniqueRatio.toFixed(3)}` }
|
|
23
|
+
|
|
24
|
+
const dominance = maxValue(counts) / graphemes.length
|
|
25
|
+
if (dominance > MAX_DOMINANCE) return { ok: false, reason: `char-dominance:${dominance.toFixed(2)}` }
|
|
26
|
+
|
|
27
|
+
return { ok: true }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function findLongestRun(graphemes: readonly string[]): number {
|
|
31
|
+
if (graphemes.length === 0) return 0
|
|
32
|
+
let longest = 1
|
|
33
|
+
let current = 1
|
|
34
|
+
for (let i = 1; i < graphemes.length; i++) {
|
|
35
|
+
if (graphemes[i] === graphemes[i - 1]) {
|
|
36
|
+
current++
|
|
37
|
+
if (current > longest) longest = current
|
|
38
|
+
} else {
|
|
39
|
+
current = 1
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return longest
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function countGraphemes(graphemes: readonly string[]): Map<string, number> {
|
|
46
|
+
const counts = new Map<string, number>()
|
|
47
|
+
for (const grapheme of graphemes) counts.set(grapheme, (counts.get(grapheme) ?? 0) + 1)
|
|
48
|
+
return counts
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function maxValue(counts: Map<string, number>): number {
|
|
52
|
+
let max = 0
|
|
53
|
+
for (const value of counts.values()) {
|
|
54
|
+
if (value > max) max = value
|
|
55
|
+
}
|
|
56
|
+
return max
|
|
57
|
+
}
|
package/src/channels/router.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
|
|
|
3
3
|
import type { AssistantMessage } from '@mariozechner/pi-ai'
|
|
4
4
|
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
5
5
|
|
|
6
|
-
import { createSession, type AgentSession } from '@/agent'
|
|
6
|
+
import { createSession, renderTurnTimeAnchor, type AgentSession } from '@/agent'
|
|
7
7
|
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
8
8
|
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
9
9
|
import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type MembershipResolverResult,
|
|
22
22
|
} from './membership'
|
|
23
23
|
import { createMembershipCache, type MembershipCache } from './membership-cache'
|
|
24
|
+
import { checkOutboundFlood } from './outbound-flood-filter'
|
|
24
25
|
import { updateParticipants } from './participants'
|
|
25
26
|
import {
|
|
26
27
|
channelsSessionsPath,
|
|
@@ -40,6 +41,7 @@ import type {
|
|
|
40
41
|
FetchHistoryArgs,
|
|
41
42
|
FetchHistoryResult,
|
|
42
43
|
HistoryCallback,
|
|
44
|
+
InboundAttachment,
|
|
43
45
|
InboundMessage,
|
|
44
46
|
OutboundCallback,
|
|
45
47
|
OutboundMessage,
|
|
@@ -106,6 +108,7 @@ export const SEND_RATE_WINDOW_MS = 5_000
|
|
|
106
108
|
// send still emits a structured log line regardless of rate — this
|
|
107
109
|
// constant only controls when the warning marker appears.
|
|
108
110
|
export const SEND_RATE_WARN_THRESHOLD = 3
|
|
111
|
+
export const OUTBOUND_FLOOD_ERROR = 'outbound message denied: content looks like a repeated-character flood'
|
|
109
112
|
|
|
110
113
|
/**
|
|
111
114
|
* Maximum age of the last engaged inbound before the next inbound triggers a fresh session.
|
|
@@ -216,6 +219,7 @@ export type ConfigForAdapter = (adapter: ChannelKey['adapter']) => ChannelAdapte
|
|
|
216
219
|
|
|
217
220
|
type QueuedInbound = {
|
|
218
221
|
text: string
|
|
222
|
+
attachments?: readonly InboundAttachment[]
|
|
219
223
|
authorId: string
|
|
220
224
|
authorName: string
|
|
221
225
|
authorIsBot: boolean
|
|
@@ -234,6 +238,7 @@ type QueuedInbound = {
|
|
|
234
238
|
|
|
235
239
|
type ObservedInbound = {
|
|
236
240
|
text: string
|
|
241
|
+
attachments?: readonly InboundAttachment[]
|
|
237
242
|
authorId: string
|
|
238
243
|
authorName: string
|
|
239
244
|
authorIsBot: boolean
|
|
@@ -447,6 +452,8 @@ export type ChannelRouter = {
|
|
|
447
452
|
registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
|
|
448
453
|
unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
|
|
449
454
|
fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
|
|
455
|
+
lookupInboundAttachment: (args: ChannelKey & { id: number }) => InboundAttachment | null
|
|
456
|
+
listInboundAttachmentIds: (args: ChannelKey) => readonly number[]
|
|
450
457
|
// Execute a command by name against an existing live session, bypassing
|
|
451
458
|
// the inbound classifier, engagement gate, debounce, and prompt queue.
|
|
452
459
|
// Used by adapters that receive commands through a native surface
|
|
@@ -491,13 +498,15 @@ export type ChannelRouter = {
|
|
|
491
498
|
// turn cannot drop a future legitimate reply.
|
|
492
499
|
//
|
|
493
500
|
// Returns:
|
|
494
|
-
// - 'recorded' —
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
// reply
|
|
501
|
+
// - 'recorded' — silence-first: no send had landed this turn, so the
|
|
502
|
+
// skip was stamped and later tool-source sends are
|
|
503
|
+
// locked out via the send-after-skip guard in `send()`
|
|
504
|
+
// - 'recorded-after-send' — reply-first: a tool-source channel send already
|
|
505
|
+
// landed this turn and the agent is now going quiet for
|
|
506
|
+
// the rest of it (the normal ack-then-wait pattern). The
|
|
507
|
+
// delivered reply stands; this skip posts nothing and is
|
|
508
|
+
// a terminal no-op. NOT stamped as a skipped turn (a
|
|
509
|
+
// reply already landed), and logged inline by the impl.
|
|
501
510
|
// - 'no-live-session' — no matching channel session (e.g. tool fired
|
|
502
511
|
// outside a channel origin); the tool should
|
|
503
512
|
// still log the reason but cannot suppress.
|
|
@@ -506,7 +515,7 @@ export type ChannelRouter = {
|
|
|
506
515
|
reason: string
|
|
507
516
|
}) =>
|
|
508
517
|
| { kind: 'recorded'; keyId: string }
|
|
509
|
-
| { kind: '
|
|
518
|
+
| { kind: 'recorded-after-send'; keyId: string }
|
|
510
519
|
| { kind: 'no-live-session' }
|
|
511
520
|
stop: () => Promise<void>
|
|
512
521
|
liveCount: () => number
|
|
@@ -1635,6 +1644,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1635
1644
|
const observe = (live: LiveSession, event: InboundMessage): void => {
|
|
1636
1645
|
live.contextBuffer.push({
|
|
1637
1646
|
text: event.text,
|
|
1647
|
+
...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
|
|
1638
1648
|
authorId: event.authorId,
|
|
1639
1649
|
authorName: event.authorName,
|
|
1640
1650
|
authorIsBot: event.authorIsBot,
|
|
@@ -1650,6 +1660,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1650
1660
|
const enqueue = (live: LiveSession, event: InboundMessage): void => {
|
|
1651
1661
|
live.promptQueue.push({
|
|
1652
1662
|
text: event.text,
|
|
1663
|
+
...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
|
|
1653
1664
|
authorId: event.authorId,
|
|
1654
1665
|
authorName: event.authorName,
|
|
1655
1666
|
authorIsBot: event.authorIsBot,
|
|
@@ -1798,6 +1809,39 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1798
1809
|
return lastError
|
|
1799
1810
|
}
|
|
1800
1811
|
|
|
1812
|
+
const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
|
|
1813
|
+
const live = liveSessions.get(channelKeyId(args))
|
|
1814
|
+
if (live === undefined) return null
|
|
1815
|
+
// Walk newest → oldest so that when an id collides across messages
|
|
1816
|
+
// (e.g. two photos in the same session each labelled `#1`) the agent's
|
|
1817
|
+
// `attachment_id: 1` always resolves to the CURRENT inbound's
|
|
1818
|
+
// attachment. promptQueue holds the about-to-be-delivered turn and
|
|
1819
|
+
// is therefore the freshest; within each list, append-order maps to
|
|
1820
|
+
// wall-clock order, so iterating in reverse gives recency.
|
|
1821
|
+
const haystacks: ReadonlyArray<ReadonlyArray<{ attachments?: readonly InboundAttachment[] }>> = [
|
|
1822
|
+
live.promptQueue,
|
|
1823
|
+
live.contextBuffer,
|
|
1824
|
+
]
|
|
1825
|
+
for (const haystack of haystacks) {
|
|
1826
|
+
for (let i = haystack.length - 1; i >= 0; i--) {
|
|
1827
|
+
const item = haystack[i]
|
|
1828
|
+
const found = item?.attachments?.find((attachment) => attachment.id === args.id)
|
|
1829
|
+
if (found !== undefined) return found
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
return null
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const listInboundAttachmentIds = (args: ChannelKey): readonly number[] => {
|
|
1836
|
+
const live = liveSessions.get(channelKeyId(args))
|
|
1837
|
+
if (live === undefined) return []
|
|
1838
|
+
const ids = new Set<number>()
|
|
1839
|
+
for (const item of [...live.promptQueue, ...live.contextBuffer]) {
|
|
1840
|
+
for (const attachment of item.attachments ?? []) ids.add(attachment.id)
|
|
1841
|
+
}
|
|
1842
|
+
return Array.from(ids).sort((a, b) => a - b)
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1801
1845
|
const send = async (msg: OutboundMessage, opts?: SendOptions): Promise<SendResult> => {
|
|
1802
1846
|
const source: SendSource = opts?.source ?? 'tool'
|
|
1803
1847
|
const callbacks = outboundCallbacks.get(msg.adapter)
|
|
@@ -1805,6 +1849,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1805
1849
|
return { ok: false, error: `no adapter registered for "${msg.adapter}"`, code: 'no-adapter' }
|
|
1806
1850
|
}
|
|
1807
1851
|
|
|
1852
|
+
const authoredText = normalizeSendText(msg.text)
|
|
1853
|
+
if (authoredText !== undefined) {
|
|
1854
|
+
const flood = checkOutboundFlood(authoredText)
|
|
1855
|
+
if (!flood.ok) return { ok: false, error: OUTBOUND_FLOOD_ERROR, code: 'outbound-flood' }
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1808
1858
|
const keyId = channelKeyId({
|
|
1809
1859
|
adapter: msg.adapter,
|
|
1810
1860
|
workspace: msg.workspace,
|
|
@@ -1982,6 +2032,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1982
2032
|
return
|
|
1983
2033
|
}
|
|
1984
2034
|
|
|
2035
|
+
if (isLikelyPlainTextChannelToolCall(assistantText)) {
|
|
2036
|
+
logger.warn(`[channels] ${live.keyId}: suppressed plain_text_channel_tool_call text_len=${assistantText.length}`)
|
|
2037
|
+
return
|
|
2038
|
+
}
|
|
2039
|
+
|
|
1985
2040
|
// `source` distinguishes the two recovery shapes for log triage:
|
|
1986
2041
|
// - 'leaf': the assistant message IS the leaf (existing behavior; model
|
|
1987
2042
|
// ended its turn with text but forgot to call channel_reply).
|
|
@@ -2201,13 +2256,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2201
2256
|
reason: string
|
|
2202
2257
|
}):
|
|
2203
2258
|
| { kind: 'recorded'; keyId: string }
|
|
2204
|
-
| { kind: '
|
|
2259
|
+
| { kind: 'recorded-after-send'; keyId: string }
|
|
2205
2260
|
| { kind: 'no-live-session' } => {
|
|
2206
2261
|
for (const live of liveSessions.values()) {
|
|
2207
2262
|
if (live.destroyed) continue
|
|
2208
2263
|
if (live.sessionId !== args.parentSessionId) continue
|
|
2209
2264
|
if (live.successfulChannelSends > live.successfulSendsAtTurnStart) {
|
|
2210
|
-
|
|
2265
|
+
// Reply-first skip ("acked, now going quiet"): accept as a terminal
|
|
2266
|
+
// no-op, never stamp `skippedTurn`. The delivered reply stands and must
|
|
2267
|
+
// not be suppressed, so stamping (which `validateChannelTurn` reads to
|
|
2268
|
+
// drop the turn) would be wrong; the send-after-skip lock only needs to
|
|
2269
|
+
// arm on the silence-first path. Rejecting this instead deadlocks the
|
|
2270
|
+
// agentic loop: denied a clean silent exit the model re-sends, gets
|
|
2271
|
+
// re-denied, and repeats until the per-turn send cap trips. Logged here
|
|
2272
|
+
// since `validateChannelTurn` won't see a `skippedTurn` for it.
|
|
2273
|
+
logger.info(`[channels] ${live.keyId} skip_after_send reason=${JSON.stringify(args.reason)}`)
|
|
2274
|
+
return { kind: 'recorded-after-send', keyId: live.keyId }
|
|
2211
2275
|
}
|
|
2212
2276
|
live.skippedTurn = { turnSeq: live.turnSeq, reason: args.reason }
|
|
2213
2277
|
return { kind: 'recorded', keyId: live.keyId }
|
|
@@ -2234,6 +2298,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2234
2298
|
registerFetchAttachment,
|
|
2235
2299
|
unregisterFetchAttachment,
|
|
2236
2300
|
fetchAttachment,
|
|
2301
|
+
lookupInboundAttachment,
|
|
2302
|
+
listInboundAttachmentIds,
|
|
2237
2303
|
executeCommand,
|
|
2238
2304
|
getSelfAliases: computeSelfAliases,
|
|
2239
2305
|
injectSubagentCompletionReminder,
|
|
@@ -2306,12 +2372,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2306
2372
|
function composeTurnPrompt(
|
|
2307
2373
|
observed: readonly ObservedInbound[],
|
|
2308
2374
|
batch: readonly QueuedInbound[],
|
|
2309
|
-
state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[] } = {
|
|
2375
|
+
state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[]; now?: Date } = {
|
|
2310
2376
|
loopGuardActive: false,
|
|
2311
2377
|
},
|
|
2312
2378
|
): string {
|
|
2313
2379
|
const adapter = state.adapter ?? 'discord-bot'
|
|
2314
2380
|
const parts: string[] = []
|
|
2381
|
+
parts.push(renderTurnTimeAnchor(state.now), '')
|
|
2315
2382
|
// System reminders (subagent-completion wakeups today) lead the turn body
|
|
2316
2383
|
// because they are typically what triggered the drain — when the prompt
|
|
2317
2384
|
// queue is empty and the only thing in this iteration is a reminder, the
|
|
@@ -2503,18 +2570,20 @@ export type QuoteAnchorCandidate = {
|
|
|
2503
2570
|
hadInterveningObserved: boolean
|
|
2504
2571
|
}
|
|
2505
2572
|
|
|
2506
|
-
// Strips `[<Adapter>
|
|
2573
|
+
// Strips both current `[<Adapter> attachment #N: ...]` and legacy
|
|
2574
|
+
// `[<Adapter> message with ...]` placeholders that adapter
|
|
2507
2575
|
// classifiers synthesize for non-text inbounds (KakaoTalk stickers,
|
|
2508
2576
|
// Slack/Discord/Telegram attachments). The quote anchor is a UX
|
|
2509
2577
|
// affordance pointing the human at *their words* — quoting a sticker as
|
|
2510
|
-
// `> Alice: [KakaoTalk
|
|
2578
|
+
// `> Alice: [KakaoTalk attachment #1: sticker name=...]`
|
|
2511
2579
|
// is noise, and for mixed inbounds like `사진 [KakaoTalk message with
|
|
2512
2580
|
// photo 1254x1254 ...]` the human only wrote `사진`, so the placeholder
|
|
2513
2581
|
// is the wrong thing to surface. The callsite (captureQuoteCandidate)
|
|
2514
2582
|
// treats an empty residue as "no quote anchor"; mixed inbounds keep the
|
|
2515
2583
|
// human-written portion. renderQuoteAnchor later collapses whitespace
|
|
2516
2584
|
// so residual double-spaces from mid-string strips are harmless.
|
|
2517
|
-
const CHANNEL_MEDIA_PLACEHOLDER_RE =
|
|
2585
|
+
const CHANNEL_MEDIA_PLACEHOLDER_RE =
|
|
2586
|
+
/\[(?:KakaoTalk|Slack|Discord|Telegram) (?:message with|attachment #\d+:) [^\]]*\]/g
|
|
2518
2587
|
|
|
2519
2588
|
export function stripChannelMediaPlaceholders(text: string): string {
|
|
2520
2589
|
return text
|
|
@@ -2944,6 +3013,36 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
|
|
|
2944
3013
|
return KIMI_CHANNEL_TOOL_ID_RE.test(text)
|
|
2945
3014
|
}
|
|
2946
3015
|
|
|
3016
|
+
// Detects the *plain-text* shape of a leaked channel-tool invocation — the
|
|
3017
|
+
// model serialized the tool call as ordinary prose instead of producing a
|
|
3018
|
+
// real tool call. Observed against Kimi-family deployments on KakaoTalk:
|
|
3019
|
+
// the entire assistant message body is literally
|
|
3020
|
+
//
|
|
3021
|
+
// channel_reply({"text":"<the user-facing greeting the bot meant to send>"})
|
|
3022
|
+
//
|
|
3023
|
+
// with no Kimi delimiter tokens (`<|tool_call_begin|>` etc.), so
|
|
3024
|
+
// `isLikelyKimiChannelToolLeak` cannot catch it. Without a guard the
|
|
3025
|
+
// recovery path in `validateChannelTurn` posts this raw function-call
|
|
3026
|
+
// serialization straight to the channel, which is exactly what
|
|
3027
|
+
// users see in the reported screenshots.
|
|
3028
|
+
//
|
|
3029
|
+
// Structural-only detection (NOT a substring search): the trimmed text must
|
|
3030
|
+
// *start* with `channel_reply(` or `channel_send(`, and that opening paren
|
|
3031
|
+
// must enclose at least one `"` (the JSON argument). This deliberately
|
|
3032
|
+
// matches the leak shape while letting prose that merely *mentions* the
|
|
3033
|
+
// tool name (e.g. "I would normally call channel_reply here but...") reach
|
|
3034
|
+
// the user — that false-positive class is already locked in by the
|
|
3035
|
+
// `still recovers legit prose that happens to mention "channel_reply"` test.
|
|
3036
|
+
//
|
|
3037
|
+
// The trailing close paren is NOT required: the model sometimes truncates
|
|
3038
|
+
// mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
|
|
3039
|
+
// just as user-hostile as the full shape.
|
|
3040
|
+
const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^channel_(?:reply|send)\s*\(\s*[^)]*"/
|
|
3041
|
+
|
|
3042
|
+
export function isLikelyPlainTextChannelToolCall(text: string): boolean {
|
|
3043
|
+
return PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.test(text.trim())
|
|
3044
|
+
}
|
|
3045
|
+
|
|
2947
3046
|
function describe(err: unknown): string {
|
|
2948
3047
|
return err instanceof Error ? err.message : String(err)
|
|
2949
3048
|
}
|
package/src/channels/types.ts
CHANGED
|
@@ -7,12 +7,56 @@ export type ChannelKey = {
|
|
|
7
7
|
thread: string | null
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
// Inbound (non-text) media that the user attached to a channel message.
|
|
11
|
+
// The classifier produces these alongside `InboundMessage.text`; the router
|
|
12
|
+
// stores them and lets channel tools look them up by `id` so the agent can
|
|
13
|
+
// fetch / view a specific attachment without ever seeing the underlying
|
|
14
|
+
// platform-side `ref` (URL, file id, CDN key) in its prompt context.
|
|
15
|
+
//
|
|
16
|
+
// Design contract:
|
|
17
|
+
// - `id` is a 1-based index that is stable WITHIN A SINGLE inbound message
|
|
18
|
+
// and assigned by the adapter classifier. It is NOT globally unique —
|
|
19
|
+
// different inbounds re-use small ids (1, 2, ...). The router's lookup
|
|
20
|
+
// scopes the search to one (adapter,workspace,chat,thread) session and
|
|
21
|
+
// returns the MOST RECENT match across that session's promptQueue +
|
|
22
|
+
// contextBuffer, so within a single turn the agent always resolves
|
|
23
|
+
// `attachment_id: 1` to the attachment on the current inbound — earlier
|
|
24
|
+
// uses of id 1 from buffered context cannot intercept the lookup.
|
|
25
|
+
// - `ref` is the opaque platform handle that the adapter's
|
|
26
|
+
// FetchAttachmentCallback knows how to download (Slack file id, Discord
|
|
27
|
+
// CDN URL, KakaoCDN URL, Telegram file_id). It is INTENTIONALLY not
|
|
28
|
+
// rendered into the user-visible prompt text — keeping it out of the
|
|
29
|
+
// LLM's context prevents the dialect-confusion bug where the agent
|
|
30
|
+
// pastes a malformed ref (e.g. a KakaoCDN bare key) into a tool.
|
|
31
|
+
// - The kind labels (photo/video/...) are coarse on purpose: they exist
|
|
32
|
+
// for the prompt placeholder ("an image arrived") and for tool routing,
|
|
33
|
+
// not for platform-specific behavior.
|
|
34
|
+
export type InboundAttachment = {
|
|
35
|
+
id: number
|
|
36
|
+
kind: 'photo' | 'video' | 'audio' | 'file' | 'sticker' | 'multiphoto' | 'embed'
|
|
37
|
+
ref: string
|
|
38
|
+
// Optional metadata that the adapter classifier may surface for the
|
|
39
|
+
// placeholder rendering. Every field MUST be safe to print into a prompt
|
|
40
|
+
// (no credentials, no long opaque tokens). If a piece of metadata would
|
|
41
|
+
// leak fetchable state, leave it off and rely on `ref` instead.
|
|
42
|
+
mimetype?: string
|
|
43
|
+
filename?: string
|
|
44
|
+
width?: number
|
|
45
|
+
height?: number
|
|
46
|
+
sizeBytes?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
10
49
|
export type InboundMessage = {
|
|
11
50
|
adapter: AdapterId
|
|
12
51
|
workspace: string
|
|
13
52
|
chat: string
|
|
14
53
|
thread: string | null
|
|
15
54
|
text: string
|
|
55
|
+
// Non-text attachments the user sent on this inbound. Empty / omitted
|
|
56
|
+
// when the message is text-only. The router carries these through to
|
|
57
|
+
// the live session's promptQueue/contextBuffer so channel tools can
|
|
58
|
+
// resolve `attachment_id` → ref without the agent ever seeing the ref.
|
|
59
|
+
attachments?: readonly InboundAttachment[]
|
|
16
60
|
externalMessageId: string
|
|
17
61
|
authorId: string
|
|
18
62
|
authorName: string
|
|
@@ -84,7 +128,13 @@ export type OutboundMessage = {
|
|
|
84
128
|
attachments?: OutboundAttachment[]
|
|
85
129
|
}
|
|
86
130
|
|
|
87
|
-
export type SendErrorCode =
|
|
131
|
+
export type SendErrorCode =
|
|
132
|
+
| 'duplicate'
|
|
133
|
+
| 'turn-cap'
|
|
134
|
+
| 'outbound-flood'
|
|
135
|
+
| 'no-adapter'
|
|
136
|
+
| 'callback-rejected'
|
|
137
|
+
| 'skip-locked'
|
|
88
138
|
|
|
89
139
|
export type SendResult = { ok: true } | { ok: false; error: string; code?: SendErrorCode }
|
|
90
140
|
|
|
@@ -124,6 +174,7 @@ export type ChannelHistoryMessage = {
|
|
|
124
174
|
authorId: string
|
|
125
175
|
authorName: string
|
|
126
176
|
text: string
|
|
177
|
+
attachments?: readonly InboundAttachment[]
|
|
127
178
|
ts: number
|
|
128
179
|
isBot: boolean
|
|
129
180
|
replyToBotMessageId: string | null
|
package/src/cli/builtins.ts
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ const main = defineCommand({
|
|
|
31
31
|
role: () => import('./role').then((m) => m.roleCommand),
|
|
32
32
|
provider: () => import('./provider').then((m) => m.providerCommand),
|
|
33
33
|
model: () => import('./model').then((m) => m.modelCommand),
|
|
34
|
+
mount: () => import('./mount').then((m) => m.mountCommand),
|
|
34
35
|
doctor: () => import('./doctor').then((m) => m.doctorCommand),
|
|
35
36
|
usage: () => import('./usage').then((m) => m.usageCommand),
|
|
36
37
|
update: () => import('./update').then((m) => m.updateCommand),
|
package/src/cli/mount.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { addMount, listMounts, removeMount, type MountListEntry } from '@/config/mounts-mutation'
|
|
4
|
+
import { findAgentDir, isInitialized } from '@/init'
|
|
5
|
+
|
|
6
|
+
import { c, errorLine, successLine } from './ui'
|
|
7
|
+
|
|
8
|
+
const listSub = defineCommand({
|
|
9
|
+
meta: {
|
|
10
|
+
name: 'list',
|
|
11
|
+
description: 'list host directories mounted into the agent container',
|
|
12
|
+
},
|
|
13
|
+
args: {
|
|
14
|
+
json: {
|
|
15
|
+
type: 'boolean',
|
|
16
|
+
description: 'emit mounts as JSON',
|
|
17
|
+
default: false,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
async run({ args }) {
|
|
21
|
+
const cwd = ensureAgentDir()
|
|
22
|
+
const mounts = listMounts(cwd)
|
|
23
|
+
if (args.json) {
|
|
24
|
+
process.stdout.write(`${JSON.stringify({ mounts }, null, 2)}\n`)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
process.stdout.write(`${formatMountList(mounts)}\n`)
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const addSub = defineCommand({
|
|
32
|
+
meta: {
|
|
33
|
+
name: 'add',
|
|
34
|
+
description: 'add a host directory mount to typeclaw.json',
|
|
35
|
+
},
|
|
36
|
+
args: {
|
|
37
|
+
name: {
|
|
38
|
+
type: 'positional',
|
|
39
|
+
description: 'mount name; appears inside the container at /agent/mounts/<name>',
|
|
40
|
+
required: true,
|
|
41
|
+
},
|
|
42
|
+
path: {
|
|
43
|
+
type: 'positional',
|
|
44
|
+
description: 'host directory path to expose inside the container',
|
|
45
|
+
required: true,
|
|
46
|
+
},
|
|
47
|
+
'read-only': {
|
|
48
|
+
type: 'boolean',
|
|
49
|
+
description: 'mount read-only inside the container',
|
|
50
|
+
default: false,
|
|
51
|
+
},
|
|
52
|
+
description: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
description: 'optional human-readable note stored in typeclaw.json',
|
|
55
|
+
required: false,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
async run({ args }) {
|
|
59
|
+
const cwd = ensureAgentDir()
|
|
60
|
+
const result = addMount(cwd, args.name, args.path, {
|
|
61
|
+
readOnly: args['read-only'] === true,
|
|
62
|
+
...(args.description !== undefined ? { description: args.description } : {}),
|
|
63
|
+
})
|
|
64
|
+
if (!result.ok) {
|
|
65
|
+
console.error(errorLine(result.reason))
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
process.stdout.write(`${successLine(`Added mount "${result.entry.name}".`)}\n`)
|
|
69
|
+
process.stdout.write(`${formatMountEntry(result.entry)}\n`)
|
|
70
|
+
process.stdout.write(`${c.dim('Apply change:')} ${c.cyan('typeclaw restart')}\n`)
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const removeSub = defineCommand({
|
|
75
|
+
meta: {
|
|
76
|
+
name: 'remove',
|
|
77
|
+
description: 'remove a host directory mount from typeclaw.json',
|
|
78
|
+
},
|
|
79
|
+
args: {
|
|
80
|
+
name: {
|
|
81
|
+
type: 'positional',
|
|
82
|
+
description: 'mount name to remove',
|
|
83
|
+
required: true,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
async run({ args }) {
|
|
87
|
+
const cwd = ensureAgentDir()
|
|
88
|
+
const result = removeMount(cwd, args.name)
|
|
89
|
+
if (!result.ok) {
|
|
90
|
+
console.error(errorLine(result.reason))
|
|
91
|
+
process.exit(1)
|
|
92
|
+
}
|
|
93
|
+
process.stdout.write(`${successLine(`Removed mount "${result.removed.name}".`)}\n`)
|
|
94
|
+
process.stdout.write(`${c.dim('Apply change:')} ${c.cyan('typeclaw restart')}\n`)
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
export const mountCommand = defineCommand({
|
|
99
|
+
meta: {
|
|
100
|
+
name: 'mount',
|
|
101
|
+
description: 'manage host directories mounted into the agent container',
|
|
102
|
+
},
|
|
103
|
+
subCommands: {
|
|
104
|
+
list: listSub,
|
|
105
|
+
add: addSub,
|
|
106
|
+
remove: removeSub,
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
export function formatMountList(mounts: readonly MountListEntry[]): string {
|
|
111
|
+
if (mounts.length === 0) return c.dim('No mounts configured.')
|
|
112
|
+
|
|
113
|
+
const nameWidth = Math.max(4, ...mounts.map((m) => m.name.length))
|
|
114
|
+
const modeWidth = 'MODE'.length
|
|
115
|
+
const statusWidth = Math.max(6, ...mounts.map((m) => m.status.length))
|
|
116
|
+
const lines: string[] = []
|
|
117
|
+
lines.push(
|
|
118
|
+
c.dim(
|
|
119
|
+
`${'NAME'.padEnd(nameWidth)} ${'MODE'.padEnd(modeWidth)} ${'STATUS'.padEnd(statusWidth)} HOST PATH -> CONTAINER PATH`,
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
for (const mount of mounts) {
|
|
123
|
+
const mode = mount.readOnly ? 'ro' : 'rw'
|
|
124
|
+
const statusText = mount.status.padEnd(statusWidth)
|
|
125
|
+
const status = mount.status === 'ok' ? c.green(statusText) : c.red(statusText)
|
|
126
|
+
lines.push(
|
|
127
|
+
`${mount.name.padEnd(nameWidth)} ${mode.padEnd(modeWidth)} ${status} ${mount.resolvedPath} -> ${mount.targetPath}`,
|
|
128
|
+
)
|
|
129
|
+
if (mount.description !== undefined) {
|
|
130
|
+
lines.push(`${' '.repeat(nameWidth + modeWidth + statusWidth + 6)}${c.dim(mount.description)}`)
|
|
131
|
+
}
|
|
132
|
+
if (mount.statusReason !== undefined) {
|
|
133
|
+
lines.push(`${' '.repeat(nameWidth + modeWidth + statusWidth + 6)}${c.yellow(mount.statusReason)}`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatMountEntry(mount: MountListEntry): string {
|
|
140
|
+
const mode = mount.readOnly ? 'read-only' : 'read-write'
|
|
141
|
+
const details = [
|
|
142
|
+
`${c.dim('host:')} ${mount.resolvedPath}`,
|
|
143
|
+
`${c.dim('container:')} ${mount.targetPath}`,
|
|
144
|
+
`${c.dim('mode:')} ${mode}`,
|
|
145
|
+
]
|
|
146
|
+
if (mount.description !== undefined) details.push(`${c.dim('description:')} ${mount.description}`)
|
|
147
|
+
return details.join('\n')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ensureAgentDir(): string {
|
|
151
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
152
|
+
if (!isInitialized(cwd)) {
|
|
153
|
+
console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
|
|
154
|
+
process.exit(1)
|
|
155
|
+
}
|
|
156
|
+
return cwd
|
|
157
|
+
}
|
package/src/cli/update.ts
CHANGED
|
@@ -9,7 +9,7 @@ const MANAGERS = ['auto', 'bun', 'npm', 'pnpm', 'yarn'] as const
|
|
|
9
9
|
export const updateCommand = defineCommand({
|
|
10
10
|
meta: {
|
|
11
11
|
name: 'update',
|
|
12
|
-
description: 'update the
|
|
12
|
+
description: 'update the installed typeclaw CLI (auto-detects global vs local)',
|
|
13
13
|
},
|
|
14
14
|
args: {
|
|
15
15
|
manager: {
|
|
@@ -42,8 +42,9 @@ export const updateCommand = defineCommand({
|
|
|
42
42
|
return
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
const scopeLabel = plan.scope === 'global' ? 'global' : `local (${plan.cwd ?? '.'})`
|
|
46
|
+
process.stdout.write(`${c.cyan(`Updating TypeClaw [${scopeLabel}] with:`)} ${rendered}\n`)
|
|
47
|
+
const exitCode = await runUpdateCommand(plan.command, plan.cwd)
|
|
47
48
|
if (exitCode !== 0) {
|
|
48
49
|
console.error(errorLine(`Update command exited with code ${exitCode}.`))
|
|
49
50
|
process.exit(exitCode)
|
|
@@ -58,7 +59,7 @@ function parseManager(value: string | undefined): UpdateManagerSelection | null
|
|
|
58
59
|
return (MANAGERS as readonly string[]).includes(value) ? (value as UpdateManagerSelection) : null
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
async function runUpdateCommand(command: string[]): Promise<number> {
|
|
62
|
+
async function runUpdateCommand(command: string[], cwd: string | undefined): Promise<number> {
|
|
62
63
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
63
64
|
if (!bun) {
|
|
64
65
|
console.error(errorLine('bun runtime not available'))
|
|
@@ -67,6 +68,7 @@ async function runUpdateCommand(command: string[]): Promise<number> {
|
|
|
67
68
|
try {
|
|
68
69
|
const proc = bun.spawn({
|
|
69
70
|
cmd: command,
|
|
71
|
+
cwd,
|
|
70
72
|
stdin: 'inherit',
|
|
71
73
|
stdout: 'inherit',
|
|
72
74
|
stderr: 'inherit',
|