typeclaw 0.1.2 → 0.1.4
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/README.md +4 -0
- package/auth.schema.json +238 -7
- package/package.json +1 -1
- package/secrets.schema.json +238 -7
- package/src/agent/auth.ts +19 -38
- package/src/agent/tools/channel-fetch-attachment.ts +6 -0
- package/src/agent/tools/channel-history.ts +10 -1
- package/src/agent/tools/channel-log.ts +32 -0
- package/src/agent/tools/channel-reply.ts +18 -1
- package/src/agent/tools/channel-send.ts +13 -1
- package/src/bundled-plugins/tool-result-cap/README.md +67 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
- package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
- package/src/channels/adapters/kakaotalk.ts +25 -16
- package/src/channels/manager.ts +47 -38
- package/src/cli/channel.ts +3 -3
- package/src/cli/index.ts +3 -0
- package/src/cli/init.ts +2 -1
- package/src/cli/ui.ts +11 -0
- package/src/config/config.ts +61 -4
- package/src/container/index.ts +2 -0
- package/src/container/start.ts +98 -2
- package/src/doctor/checks.ts +7 -27
- package/src/doctor/commit.ts +44 -3
- package/src/doctor/plugin-bridge.ts +19 -0
- package/src/hostd/daemon.ts +28 -3
- package/src/hostd/protocol.ts +7 -0
- package/src/init/auto-upgrade.ts +368 -0
- package/src/init/dockerfile.ts +83 -14
- package/src/init/index.ts +123 -77
- package/src/init/kakaotalk-auth.ts +9 -3
- package/src/init/run-bun-install.ts +34 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/index.ts +9 -0
- package/src/secrets/defaults.ts +67 -0
- package/src/secrets/hydrate.ts +99 -0
- package/src/secrets/index.ts +6 -12
- package/src/secrets/kakao-store.ts +129 -0
- package/src/secrets/migrate-kakaotalk.ts +82 -0
- package/src/secrets/migrate.ts +5 -4
- package/src/secrets/resolve.ts +57 -0
- package/src/secrets/schema.ts +162 -42
- package/src/secrets/storage.ts +253 -47
- package/src/skills/typeclaw-config/SKILL.md +47 -8
- package/typeclaw.schema.json +49 -2
- package/src/secrets/env.ts +0 -43
|
@@ -7,6 +7,8 @@ import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
|
7
7
|
import type { ChannelRouter } from '@/channels/router'
|
|
8
8
|
import type { AdapterId } from '@/channels/schema'
|
|
9
9
|
|
|
10
|
+
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
11
|
+
|
|
10
12
|
export type ChannelFetchAttachmentOrigin = {
|
|
11
13
|
adapter: AdapterId
|
|
12
14
|
}
|
|
@@ -15,6 +17,7 @@ export type CreateChannelFetchAttachmentToolOptions = {
|
|
|
15
17
|
router: ChannelRouter
|
|
16
18
|
origin: ChannelFetchAttachmentOrigin
|
|
17
19
|
inboxDir?: string
|
|
20
|
+
logger?: ChannelToolLogger
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export const DEFAULT_INBOX_DIR = '/agent/workspace/inbox'
|
|
@@ -23,6 +26,7 @@ export function createChannelFetchAttachmentTool({
|
|
|
23
26
|
router,
|
|
24
27
|
origin,
|
|
25
28
|
inboxDir,
|
|
29
|
+
logger = consoleChannelLogger,
|
|
26
30
|
}: CreateChannelFetchAttachmentToolOptions) {
|
|
27
31
|
const baseDir = inboxDir ?? DEFAULT_INBOX_DIR
|
|
28
32
|
const adapter = origin.adapter
|
|
@@ -60,6 +64,7 @@ export function createChannelFetchAttachmentTool({
|
|
|
60
64
|
...(params.filename !== undefined ? { filename: params.filename } : {}),
|
|
61
65
|
})
|
|
62
66
|
if (!result.ok) {
|
|
67
|
+
logger.warn(formatChannelToolFailure('channel_fetch_attachment', `${adapter}: ${result.error}`))
|
|
63
68
|
const text = `channel_fetch_attachment error: ${result.error}`
|
|
64
69
|
const details: Details = { ok: false, error: result.error }
|
|
65
70
|
return { content: [{ type: 'text' as const, text }], details }
|
|
@@ -74,6 +79,7 @@ export function createChannelFetchAttachmentTool({
|
|
|
74
79
|
await writeFile(targetPath, result.buffer)
|
|
75
80
|
} catch (err) {
|
|
76
81
|
const message = err instanceof Error ? err.message : String(err)
|
|
82
|
+
logger.warn(formatChannelToolFailure('channel_fetch_attachment', `${adapter}: write failed: ${message}`))
|
|
77
83
|
const text = `channel_fetch_attachment error: write failed: ${message}`
|
|
78
84
|
const details: Details = { ok: false, error: `write failed: ${message}` }
|
|
79
85
|
return { content: [{ type: 'text' as const, text }], details }
|
|
@@ -5,6 +5,8 @@ import type { ChannelRouter } from '@/channels/router'
|
|
|
5
5
|
import type { AdapterId } from '@/channels/schema'
|
|
6
6
|
import type { ChannelHistoryMessage } from '@/channels/types'
|
|
7
7
|
|
|
8
|
+
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
9
|
+
|
|
8
10
|
export type ChannelHistoryOrigin = {
|
|
9
11
|
adapter: AdapterId
|
|
10
12
|
workspace: string
|
|
@@ -15,6 +17,7 @@ export type ChannelHistoryOrigin = {
|
|
|
15
17
|
export type CreateChannelHistoryToolOptions = {
|
|
16
18
|
router: ChannelRouter
|
|
17
19
|
origin: ChannelHistoryOrigin
|
|
20
|
+
logger?: ChannelToolLogger
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
// channel_history is a lazy "look back" capability for channel-routed
|
|
@@ -27,7 +30,11 @@ export type CreateChannelHistoryToolOptions = {
|
|
|
27
30
|
// `scope` defaults to thread when the origin has one, channel otherwise.
|
|
28
31
|
// Thread scope on a channel-root session is rejected rather than silently
|
|
29
32
|
// downgraded so the agent doesn't conflate the two views.
|
|
30
|
-
export function createChannelHistoryTool({
|
|
33
|
+
export function createChannelHistoryTool({
|
|
34
|
+
router,
|
|
35
|
+
origin,
|
|
36
|
+
logger = consoleChannelLogger,
|
|
37
|
+
}: CreateChannelHistoryToolOptions) {
|
|
31
38
|
return defineTool({
|
|
32
39
|
name: 'channel_history',
|
|
33
40
|
label: 'Channel History',
|
|
@@ -64,6 +71,7 @@ export function createChannelHistoryTool({ router, origin }: CreateChannelHistor
|
|
|
64
71
|
type Details = { ok: boolean; error?: string; count?: number; nextCursor?: string }
|
|
65
72
|
|
|
66
73
|
if (scope === 'thread' && origin.thread === null) {
|
|
74
|
+
logger.warn(formatChannelToolFailure('channel_history', 'thread-scope-requires-thread-session'))
|
|
67
75
|
const text =
|
|
68
76
|
'channel_history error: thread-scope-requires-thread-session — this session is not in a thread; pass `scope: "channel"` instead.'
|
|
69
77
|
const details: Details = { ok: false, error: 'thread-scope-requires-thread-session' }
|
|
@@ -78,6 +86,7 @@ export function createChannelHistoryTool({ router, origin }: CreateChannelHistor
|
|
|
78
86
|
})
|
|
79
87
|
|
|
80
88
|
if (!result.ok) {
|
|
89
|
+
logger.warn(formatChannelToolFailure('channel_history', `${origin.adapter}:${origin.chat}: ${result.error}`))
|
|
81
90
|
const details: Details = { ok: false, error: result.error }
|
|
82
91
|
return {
|
|
83
92
|
content: [{ type: 'text' as const, text: `channel_history error: ${result.error}` }],
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Shared logger surface for the channel_* agent tools.
|
|
2
|
+
//
|
|
3
|
+
// Until now, channel_send / channel_reply / channel_history /
|
|
4
|
+
// channel_fetch_attachment swallowed every failure into the model-visible
|
|
5
|
+
// tool result and emitted nothing to the container's stdout/stderr. That
|
|
6
|
+
// made operator-side debugging blind: a Slack send that 403'd, a
|
|
7
|
+
// `thread-scope-requires-thread-session` denial, or a Discord attachment
|
|
8
|
+
// fetch that timed out left no trace in `typeclaw logs`. The router layer
|
|
9
|
+
// logs some of these (e.g. `fetchHistory` warns on caught exceptions) but
|
|
10
|
+
// does NOT log `router.send` rejections, and pre-router validation errors
|
|
11
|
+
// inside the tools (missing text, NO_REPLY misuse, thread-scope mismatch,
|
|
12
|
+
// local write failures) never reached the router in the first place.
|
|
13
|
+
//
|
|
14
|
+
// One injectable logger per tool keeps the existing fake-router test
|
|
15
|
+
// pattern intact: tests pass an array-collecting logger to assert the log
|
|
16
|
+
// line, production code defaults to `consoleChannelLogger` which routes to
|
|
17
|
+
// `console.warn` so it lands in `typeclaw logs` alongside the existing
|
|
18
|
+
// `[channels]` lines from manager.ts / router.ts.
|
|
19
|
+
export type ChannelToolLogger = {
|
|
20
|
+
warn: (msg: string) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const consoleChannelLogger: ChannelToolLogger = {
|
|
24
|
+
warn: (m) => console.warn(m),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Format a failure log line. Keeps the `[channels]` prefix used by
|
|
28
|
+
// manager.ts and router.ts so operators can `grep '\[channels\]'` and see
|
|
29
|
+
// the full stack of channel-related warnings in one pass.
|
|
30
|
+
export function formatChannelToolFailure(tool: string, error: string): string {
|
|
31
|
+
return `[channels] ${tool} failed: ${error}`
|
|
32
|
+
}
|
|
@@ -4,6 +4,8 @@ import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
|
4
4
|
import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
|
|
5
5
|
import type { AdapterId } from '@/channels/schema'
|
|
6
6
|
|
|
7
|
+
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
8
|
+
|
|
7
9
|
export type ChannelReplyOrigin = {
|
|
8
10
|
adapter: AdapterId
|
|
9
11
|
workspace: string
|
|
@@ -14,6 +16,7 @@ export type ChannelReplyOrigin = {
|
|
|
14
16
|
export type CreateChannelReplyToolOptions = {
|
|
15
17
|
router: ChannelRouter
|
|
16
18
|
origin: ChannelReplyOrigin
|
|
19
|
+
logger?: ChannelToolLogger
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
// channel_reply is the happy-path companion to channel_send for channel-routed
|
|
@@ -25,7 +28,11 @@ export type CreateChannelReplyToolOptions = {
|
|
|
25
28
|
// channel_reply takes only `text` and addresses the message from the origin.
|
|
26
29
|
// channel_send remains for posting somewhere else (different chat, breaking
|
|
27
30
|
// out of a thread, sending DMs from a channel session, etc.).
|
|
28
|
-
export function createChannelReplyTool({
|
|
31
|
+
export function createChannelReplyTool({
|
|
32
|
+
router,
|
|
33
|
+
origin,
|
|
34
|
+
logger = consoleChannelLogger,
|
|
35
|
+
}: CreateChannelReplyToolOptions) {
|
|
29
36
|
return defineTool({
|
|
30
37
|
name: 'channel_reply',
|
|
31
38
|
label: 'Channel Reply',
|
|
@@ -64,6 +71,7 @@ export function createChannelReplyTool({ router, origin }: CreateChannelReplyToo
|
|
|
64
71
|
const text = params.text
|
|
65
72
|
const attachments = params.attachments
|
|
66
73
|
if ((text === undefined || text === '') && (attachments === undefined || attachments.length === 0)) {
|
|
74
|
+
logger.warn(formatChannelToolFailure('channel_reply', 'missing text and attachments'))
|
|
67
75
|
return {
|
|
68
76
|
content: [
|
|
69
77
|
{ type: 'text' as const, text: 'channel_reply denied: must provide `text`, `attachments`, or both.' },
|
|
@@ -74,6 +82,7 @@ export function createChannelReplyTool({ router, origin }: CreateChannelReplyToo
|
|
|
74
82
|
|
|
75
83
|
const noReplyError = noReplyMisuseError(text)
|
|
76
84
|
if (noReplyError) {
|
|
85
|
+
logger.warn(formatChannelToolFailure('channel_reply', noReplyError))
|
|
77
86
|
return {
|
|
78
87
|
content: [{ type: 'text' as const, text: `channel_reply denied: ${noReplyError}` }],
|
|
79
88
|
details: { ok: false, error: noReplyError },
|
|
@@ -89,6 +98,14 @@ export function createChannelReplyTool({ router, origin }: CreateChannelReplyToo
|
|
|
89
98
|
...(attachments !== undefined ? { attachments } : {}),
|
|
90
99
|
})
|
|
91
100
|
|
|
101
|
+
if (!result.ok) {
|
|
102
|
+
logger.warn(
|
|
103
|
+
formatChannelToolFailure(
|
|
104
|
+
'channel_reply',
|
|
105
|
+
`${origin.adapter}:${origin.workspace}/${origin.chat}: ${result.error}`,
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
}
|
|
92
109
|
const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
|
|
93
110
|
// Echo the delivered text back to the model. The adapter classifier
|
|
94
111
|
// drops self-authored messages on the inbound path (`self_author`),
|
|
@@ -4,6 +4,7 @@ import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
|
4
4
|
import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
|
|
5
5
|
import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
|
|
6
6
|
|
|
7
|
+
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
7
8
|
import { renderOutboundEcho } from './channel-reply'
|
|
8
9
|
|
|
9
10
|
export type ChannelSendOrigin = {
|
|
@@ -21,9 +22,10 @@ export type CreateChannelSendToolOptions = {
|
|
|
21
22
|
// the model can self-correct on its next turn. Absent for sessions whose
|
|
22
23
|
// origin isn't a channel (e.g. cron prompts that send to channels).
|
|
23
24
|
origin?: ChannelSendOrigin
|
|
25
|
+
logger?: ChannelToolLogger
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
export function createChannelSendTool({ router, origin }: CreateChannelSendToolOptions) {
|
|
28
|
+
export function createChannelSendTool({ router, origin, logger = consoleChannelLogger }: CreateChannelSendToolOptions) {
|
|
27
29
|
return defineTool({
|
|
28
30
|
name: 'channel_send',
|
|
29
31
|
label: 'Channel Send',
|
|
@@ -93,6 +95,7 @@ export function createChannelSendTool({ router, origin }: CreateChannelSendToolO
|
|
|
93
95
|
const bodyText = params.text
|
|
94
96
|
const attachments = params.attachments
|
|
95
97
|
if ((bodyText === undefined || bodyText === '') && (attachments === undefined || attachments.length === 0)) {
|
|
98
|
+
logger.warn(formatChannelToolFailure('channel_send', 'missing text and attachments'))
|
|
96
99
|
return {
|
|
97
100
|
content: [
|
|
98
101
|
{ type: 'text' as const, text: 'channel_send denied: must provide `text`, `attachments`, or both.' },
|
|
@@ -103,6 +106,7 @@ export function createChannelSendTool({ router, origin }: CreateChannelSendToolO
|
|
|
103
106
|
|
|
104
107
|
const noReplyError = noReplyMisuseError(bodyText)
|
|
105
108
|
if (noReplyError) {
|
|
109
|
+
logger.warn(formatChannelToolFailure('channel_send', noReplyError))
|
|
106
110
|
return {
|
|
107
111
|
content: [{ type: 'text' as const, text: `channel_send denied: ${noReplyError}` }],
|
|
108
112
|
details: { ok: false, error: noReplyError },
|
|
@@ -118,6 +122,14 @@ export function createChannelSendTool({ router, origin }: CreateChannelSendToolO
|
|
|
118
122
|
...(attachments !== undefined ? { attachments } : {}),
|
|
119
123
|
})
|
|
120
124
|
|
|
125
|
+
if (!result.ok) {
|
|
126
|
+
logger.warn(
|
|
127
|
+
formatChannelToolFailure(
|
|
128
|
+
'channel_send',
|
|
129
|
+
`${params.adapter}:${params.workspace}/${params.chat}: ${result.error}`,
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
121
133
|
const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
|
|
122
134
|
const echo = renderOutboundEcho(bodyText, attachments)
|
|
123
135
|
const baseText = result.ok
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# typeclaw-plugin-tool-result-cap
|
|
2
|
+
|
|
3
|
+
The bundled tool-result-cap plugin. Caps the size of `tool.after` results before they get persisted to the session JSONL, so a single oversized tool output cannot bloat the transcript and force the full payload to round-trip to the LLM on every subsequent turn.
|
|
4
|
+
|
|
5
|
+
This plugin is **auto-loaded** by every TypeClaw agent. There is no `plugins[]` entry to add and no opt-out short of `tool-result-cap.enabled: false`. To configure it, add a `tool-result-cap` block to `typeclaw.json`.
|
|
6
|
+
|
|
7
|
+
## Why it exists
|
|
8
|
+
|
|
9
|
+
`pi-coding-agent`'s built-in tools occasionally return very large payloads that the model only needed once. Two empirically observed cases:
|
|
10
|
+
|
|
11
|
+
1. **`read` on an image file** returns the base64-encoded image inline (e.g. `{type:"image", data:"<3.2MB of base64>"}`). The model uses it on the turn it was asked for, then sees the same 3.2MB of base64 as conversation context on every subsequent prompt — until compaction fires (which is token-driven, not byte-driven, so a single fat blob may sit in context for many turns before compaction is triggered).
|
|
12
|
+
2. **`webfetch` on a binary URL** (PNG, ZIP, etc.) receives the raw response body, treats it as text, and stores raw binary as a JSON-encoded string. Same effect: 100KB+ of mojibake sits in the transcript permanently.
|
|
13
|
+
|
|
14
|
+
The result is a session JSONL file that's tens of megabytes on disk but mostly one or two giant tool results, plus 3-minute first-prompt latencies after container restart because the full transcript gets re-shipped to the LLM as context.
|
|
15
|
+
|
|
16
|
+
`tool-result-cap` registers a `tool.after` hook that inspects every tool's result and, in place, replaces oversized image/text parts with a short placeholder before pi-coding-agent appends the entry to the JSONL. The cap happens at the wire-format level, so the bloat never reaches disk or the LLM in the first place.
|
|
17
|
+
|
|
18
|
+
## Config
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"tool-result-cap": {
|
|
23
|
+
"enabled": true,
|
|
24
|
+
"imageMaxBytes": 262144,
|
|
25
|
+
"textMaxBytes": 65536,
|
|
26
|
+
"exemptTools": []
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Field | Default | Effect |
|
|
32
|
+
| ------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| `tool-result-cap.enabled` | `true` | Master switch. When `false`, the plugin returns no hooks at all and tool results pass through untouched. |
|
|
34
|
+
| `tool-result-cap.imageMaxBytes` | `262144` | Maximum size (in bytes of the base64 string, not the decoded binary) for any `{type:"image"}` part in a tool result. Parts above this are replaced with a short text placeholder naming the original mime type and size. Default is ~256KB of base64 ≈ ~190KB of binary. Minimum `1024`. |
|
|
35
|
+
| `tool-result-cap.textMaxBytes` | `65536` | Maximum length (in characters) for any `{type:"text"}` part. Parts above this are truncated: the first `textMaxBytes` characters are kept (so the LLM sees the shape of the output), and an elision marker is appended naming the byte count dropped. Minimum `1024`. |
|
|
36
|
+
| `tool-result-cap.exemptTools` | `[]` | List of tool names to skip entirely. Use when a specific tool genuinely needs to return large payloads and you can absorb the per-turn cost. |
|
|
37
|
+
|
|
38
|
+
All fields are **restart-required** — the plugin reads them once at boot.
|
|
39
|
+
|
|
40
|
+
## How it works
|
|
41
|
+
|
|
42
|
+
The plugin registers a single `tool.after` hook. The hook receives `event.result: ToolResult` by reference, walks `result.content`, and replaces each `ContentPart` in place when it exceeds its corresponding threshold. Mutation order is unspecified across plugins, but because the wrapper in `src/agent/plugin-tools.ts` reads the same `hookResult.content` reference after the hook chain finishes, mutations are seen by pi-coding-agent and persisted to JSONL.
|
|
43
|
+
|
|
44
|
+
The cap is per-part, not per-result, so a result with one small text part and one giant image is partly preserved (small text untouched, image elided).
|
|
45
|
+
|
|
46
|
+
Placeholders carry the literal substring `tool-result-cap:` so future agents (or human operators inspecting a session) can grep for them and recognize that the original payload was intentionally elided rather than truncated by some other layer.
|
|
47
|
+
|
|
48
|
+
## What's not capped
|
|
49
|
+
|
|
50
|
+
- `details` on tool results (an opaque structured payload provider-specific to each tool — generally small, and mutating it risks breaking tool-specific telemetry).
|
|
51
|
+
- Tool calls themselves (`assistant` messages with `toolUse` content). These are bounded by the LLM's own output limits.
|
|
52
|
+
- User messages and system prompts.
|
|
53
|
+
|
|
54
|
+
## Ordering against other bundled plugins
|
|
55
|
+
|
|
56
|
+
Plugin hook order is the order plugins are listed in `src/run/bundled-plugins.ts`. `tool-result-cap` is registered **before** `guard` so guard's `tool.after` advice (the uncommitted-changes warning) appends to the already-capped content. This means a guard advice that fires on the same call sees a small text part and a placeholder, never the original oversized payload — keeping the advice text immediately legible in the JSONL.
|
|
57
|
+
|
|
58
|
+
## What it contributes
|
|
59
|
+
|
|
60
|
+
| Kind | Name | Notes |
|
|
61
|
+
| ---- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
62
|
+
| Hook | `tool.after` | Walks `event.result.content` and replaces oversized image/text parts in place. Logs one `info` line per capped call, silent otherwise. |
|
|
63
|
+
|
|
64
|
+
## Tests
|
|
65
|
+
|
|
66
|
+
- `cap-result.test.ts` — pure-function unit tests for the capping logic (image replacement, text truncation, mixed parts, exempt tools, empty results, in-place mutation invariant).
|
|
67
|
+
- `index.test.ts` — composition tests (config schema defaults and validation, hook registration, disabled-mode short-circuit, logging on cap, silence on no-op).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ContentPart, ToolResult } from '@/plugin'
|
|
2
|
+
|
|
3
|
+
export type CapOptions = {
|
|
4
|
+
imageMaxBytes: number
|
|
5
|
+
textMaxBytes: number
|
|
6
|
+
exemptTools: ReadonlySet<string>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type CapStats = {
|
|
10
|
+
imagesReplaced: number
|
|
11
|
+
textsTruncated: number
|
|
12
|
+
bytesElided: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Sentinel marker used in both image and text replacement payloads so a future
|
|
16
|
+
// pass (or the LLM itself) can recognize that a value was capped rather than
|
|
17
|
+
// truthfully short. Plain English on purpose — these strings get fed to the
|
|
18
|
+
// model on every turn, and unfamiliar tokens cost reasoning bandwidth.
|
|
19
|
+
const ELIDED_MARKER = '[tool-result-cap: '
|
|
20
|
+
|
|
21
|
+
export function capToolResult(tool: string, result: ToolResult, options: CapOptions): CapStats {
|
|
22
|
+
const stats: CapStats = { imagesReplaced: 0, textsTruncated: 0, bytesElided: 0 }
|
|
23
|
+
if (options.exemptTools.has(tool)) return stats
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < result.content.length; i++) {
|
|
26
|
+
const part = result.content[i]
|
|
27
|
+
if (!part) continue
|
|
28
|
+
if (part.type === 'image') {
|
|
29
|
+
const size = part.data.length
|
|
30
|
+
if (size <= options.imageMaxBytes) continue
|
|
31
|
+
result.content[i] = {
|
|
32
|
+
type: 'text',
|
|
33
|
+
text: `${ELIDED_MARKER}image ${part.mimeType} elided, ${size} bytes of base64 exceeded imageMaxBytes=${options.imageMaxBytes}]`,
|
|
34
|
+
}
|
|
35
|
+
stats.imagesReplaced += 1
|
|
36
|
+
stats.bytesElided += size
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (part.type === 'text') {
|
|
40
|
+
const size = part.text.length
|
|
41
|
+
if (size <= options.textMaxBytes) continue
|
|
42
|
+
// Keep a head slice of the original text so the LLM still has a hint of
|
|
43
|
+
// shape (e.g. "fetched HTML starts with <!DOCTYPE..."). Tail is dropped.
|
|
44
|
+
const head = part.text.slice(0, options.textMaxBytes)
|
|
45
|
+
const elided = size - options.textMaxBytes
|
|
46
|
+
const replacement: ContentPart = {
|
|
47
|
+
type: 'text',
|
|
48
|
+
text: `${head}\n\n${ELIDED_MARKER}${elided} bytes truncated from text part; original was ${size} bytes, textMaxBytes=${options.textMaxBytes}]`,
|
|
49
|
+
}
|
|
50
|
+
result.content[i] = replacement
|
|
51
|
+
stats.textsTruncated += 1
|
|
52
|
+
stats.bytesElided += elided
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return stats
|
|
56
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { definePlugin } from '@/plugin'
|
|
4
|
+
|
|
5
|
+
import { capToolResult } from './cap-result'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_IMAGE_MAX_BYTES = 262_144
|
|
8
|
+
const DEFAULT_TEXT_MAX_BYTES = 65_536
|
|
9
|
+
const MIN_IMAGE_MAX_BYTES = 1_024
|
|
10
|
+
const MIN_TEXT_MAX_BYTES = 1_024
|
|
11
|
+
|
|
12
|
+
const toolResultCapConfigSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
enabled: z.boolean().default(true),
|
|
15
|
+
imageMaxBytes: z.number().int().min(MIN_IMAGE_MAX_BYTES).default(DEFAULT_IMAGE_MAX_BYTES),
|
|
16
|
+
textMaxBytes: z.number().int().min(MIN_TEXT_MAX_BYTES).default(DEFAULT_TEXT_MAX_BYTES),
|
|
17
|
+
exemptTools: z.array(z.string()).default([]),
|
|
18
|
+
})
|
|
19
|
+
.default({
|
|
20
|
+
enabled: true,
|
|
21
|
+
imageMaxBytes: DEFAULT_IMAGE_MAX_BYTES,
|
|
22
|
+
textMaxBytes: DEFAULT_TEXT_MAX_BYTES,
|
|
23
|
+
exemptTools: [],
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export default definePlugin({
|
|
27
|
+
configSchema: toolResultCapConfigSchema,
|
|
28
|
+
plugin: async (ctx) => {
|
|
29
|
+
const { enabled, imageMaxBytes, textMaxBytes, exemptTools } = ctx.config
|
|
30
|
+
if (!enabled) return {}
|
|
31
|
+
|
|
32
|
+
const options = {
|
|
33
|
+
imageMaxBytes,
|
|
34
|
+
textMaxBytes,
|
|
35
|
+
exemptTools: new Set(exemptTools),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
hooks: {
|
|
40
|
+
'tool.after': (event) => {
|
|
41
|
+
const stats = capToolResult(event.tool, event.result, options)
|
|
42
|
+
if (stats.imagesReplaced > 0 || stats.textsTruncated > 0) {
|
|
43
|
+
ctx.logger.info(
|
|
44
|
+
`[tool-result-cap] capped ${event.tool} call=${event.callId}: imagesReplaced=${stats.imagesReplaced} textsTruncated=${stats.textsTruncated} bytesElided=${stats.bytesElided}`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
})
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type KakaoTalkPushEmoticonEvent,
|
|
12
12
|
type KakaoTalkPushMessageEvent,
|
|
13
13
|
} from 'agent-messenger/kakaotalk'
|
|
14
|
+
import type { KakaoAccountCredentials, KakaoConfig, PendingLoginState } from 'agent-messenger/kakaotalk'
|
|
14
15
|
|
|
15
16
|
import type { ChannelRouter } from '@/channels/router'
|
|
16
17
|
import { isAllowed, type ChannelAdapterConfig, type KakaotalkAdapterConfig } from '@/channels/schema'
|
|
@@ -75,6 +76,19 @@ export interface KakaoTalkListener {
|
|
|
75
76
|
): this
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
export type KakaoCredentialStore = {
|
|
80
|
+
load(): Promise<KakaoConfig>
|
|
81
|
+
save(config: KakaoConfig): Promise<void>
|
|
82
|
+
getAccount(id?: string): Promise<KakaoAccountCredentials | null>
|
|
83
|
+
setAccount(account: KakaoAccountCredentials): Promise<void>
|
|
84
|
+
removeAccount(id: string): Promise<void>
|
|
85
|
+
listAccounts(): Promise<Array<KakaoAccountCredentials & { is_current: boolean }>>
|
|
86
|
+
setCurrentAccount(id: string): Promise<void>
|
|
87
|
+
savePendingLogin(state: PendingLoginState): Promise<void>
|
|
88
|
+
loadPendingLogin(): Promise<PendingLoginState | null>
|
|
89
|
+
clearPendingLogin(): Promise<void>
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
const KakaoTalkClient = RealKakaoTalkClient as unknown as new () => KakaoTalkClient
|
|
79
93
|
const KakaoTalkListener = RealKakaoTalkListener as unknown as new (client: KakaoTalkClient) => KakaoTalkListener
|
|
80
94
|
|
|
@@ -95,13 +109,9 @@ export type KakaotalkAdapterOptions = {
|
|
|
95
109
|
configRef: () => KakaotalkAdapterConfig
|
|
96
110
|
logger?: KakaotalkAdapterLogger
|
|
97
111
|
selfAliasesRef?: () => readonly string[]
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
// the
|
|
101
|
-
// wiring in src/channels/manager.ts passes the agent-folder workspace
|
|
102
|
-
// path here so the adapter's credential resolution does NOT depend on
|
|
103
|
-
// process.env state — easier to test, and removes a hidden coupling
|
|
104
|
-
// with whatever set the env var (Dockerfile, CLI shell, etc.).
|
|
112
|
+
credentialsStore?: KakaoCredentialStore
|
|
113
|
+
// Deprecated compatibility path for old tests/callers. Production uses
|
|
114
|
+
// credentialsStore so secrets.json remains the credential source of truth.
|
|
105
115
|
credentialsDir?: string
|
|
106
116
|
client?: KakaoTalkClient
|
|
107
117
|
listenerFactory?: (client: KakaoTalkClient) => KakaoTalkListener
|
|
@@ -416,17 +426,16 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
416
426
|
lastConnectedAt = null
|
|
417
427
|
resetRecoveryEpisode()
|
|
418
428
|
try {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
// process.env state.
|
|
425
|
-
const credManager = new KakaoCredentialManager(options.credentialsDir)
|
|
426
|
-
const account = await credManager.getAccount()
|
|
429
|
+
const credentialStore =
|
|
430
|
+
options.credentialsStore ??
|
|
431
|
+
(options.credentialsDir !== undefined ? new KakaoCredentialManager(options.credentialsDir) : null)
|
|
432
|
+
if (credentialStore !== null) {
|
|
433
|
+
const account = await credentialStore.getAccount()
|
|
427
434
|
if (account === null) {
|
|
428
435
|
throw new Error(
|
|
429
|
-
|
|
436
|
+
options.credentialsDir !== undefined
|
|
437
|
+
? `no KakaoTalk account in ${options.credentialsDir}/kakaotalk-credentials.json (run typeclaw init to authenticate)`
|
|
438
|
+
: 'no KakaoTalk account in secrets.json#channels.kakaotalk (run typeclaw init to authenticate)',
|
|
430
439
|
)
|
|
431
440
|
}
|
|
432
441
|
await client.login({
|
package/src/channels/manager.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
3
2
|
import { join } from 'node:path'
|
|
4
3
|
|
|
4
|
+
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
5
|
+
import { SecretsBackend } from '@/secrets/storage'
|
|
6
|
+
|
|
5
7
|
import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/discord-bot'
|
|
6
8
|
import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
|
|
7
9
|
import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
|
|
@@ -62,10 +64,10 @@ type AnyAdapter = DiscordBotAdapter | KakaotalkAdapter | SlackBotAdapter | Teleg
|
|
|
62
64
|
// Credential signature is the comparison key for credential-rotation
|
|
63
65
|
// detection on reload. Discord and Telegram each use a single bot token;
|
|
64
66
|
// Slack needs both a bot token and an app-level token (Socket Mode);
|
|
65
|
-
// KakaoTalk authenticates via a
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// env-var-based adapters and KakaoTalk's
|
|
67
|
+
// KakaoTalk authenticates via a structured multi-account block in
|
|
68
|
+
// secrets.json#channels.kakaotalk, so its signature is that block's content
|
|
69
|
+
// hash. The "credential" naming (vs "token") generalizes across the
|
|
70
|
+
// env-var-based adapters and KakaoTalk's account credential pathway.
|
|
69
71
|
type AdapterEntry = {
|
|
70
72
|
adapter: AnyAdapter
|
|
71
73
|
credentialSignature: string
|
|
@@ -89,7 +91,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
89
91
|
const live = new Map<AdapterId, AdapterEntry>()
|
|
90
92
|
|
|
91
93
|
const buildCredentialSignature = (name: AdapterId): { signature: string; missing: string[] } => {
|
|
92
|
-
if (name === 'kakaotalk') return buildKakaotalkSignature(options.agentDir
|
|
94
|
+
if (name === 'kakaotalk') return buildKakaotalkSignature(options.agentDir)
|
|
93
95
|
const requiredEnvs = TOKEN_ENV[name]
|
|
94
96
|
const parts: string[] = []
|
|
95
97
|
const missing: string[] = []
|
|
@@ -132,7 +134,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
132
134
|
configRef: () => options.channelsConfigRef()[name] ?? cfg,
|
|
133
135
|
logger,
|
|
134
136
|
selfAliasesRef: () => router.getSelfAliases(),
|
|
135
|
-
|
|
137
|
+
credentialsStore: createContainerKakaoCredentialStore(options.agentDir, env),
|
|
136
138
|
})
|
|
137
139
|
}
|
|
138
140
|
if (name === 'telegram-bot') {
|
|
@@ -223,7 +225,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
223
225
|
const { signature, missing } = buildCredentialSignature(name)
|
|
224
226
|
if (missing.length > 0) {
|
|
225
227
|
// Required credentials disappeared (env vars removed from .env, or
|
|
226
|
-
// KakaoTalk credentials
|
|
228
|
+
// KakaoTalk credentials removed from secrets.json). Continuing to use the
|
|
227
229
|
// in-memory credentials would silently honor a credential the
|
|
228
230
|
// operator explicitly removed, so stop the adapter instead of
|
|
229
231
|
// waiting for a manual restart.
|
|
@@ -244,49 +246,56 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
244
246
|
}
|
|
245
247
|
}
|
|
246
248
|
|
|
247
|
-
// Token-based adapters only. KakaoTalk's credentials live in
|
|
248
|
-
//
|
|
249
|
-
//
|
|
249
|
+
// Token-based adapters only. KakaoTalk's credentials live in
|
|
250
|
+
// secrets.json#channels.kakaotalk, not in env, so it goes through
|
|
251
|
+
// buildKakaotalkSignature instead.
|
|
250
252
|
const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk'>, readonly string[]> = {
|
|
251
253
|
'discord-bot': ['DISCORD_BOT_TOKEN'],
|
|
252
254
|
'slack-bot': ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
|
|
253
255
|
'telegram-bot': ['TELEGRAM_BOT_TOKEN'],
|
|
254
256
|
}
|
|
255
257
|
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
258
|
+
function createContainerKakaoCredentialStore(agentDir: string, env: NodeJS.ProcessEnv): SecretsKakaoCredentialStore {
|
|
259
|
+
const hostdUrl = env.TYPECLAW_HOSTD_URL
|
|
260
|
+
const restartToken = env.TYPECLAW_HOSTD_TOKEN
|
|
261
|
+
const containerName = env.TYPECLAW_CONTAINER_NAME
|
|
262
|
+
if (!hostdUrl || !restartToken || !containerName) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
'KakaoTalk credentials require TYPECLAW_HOSTD_URL, TYPECLAW_HOSTD_TOKEN, and TYPECLAW_CONTAINER_NAME',
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
return new SecretsKakaoCredentialStore({
|
|
268
|
+
mode: 'container',
|
|
269
|
+
secretsPath: join(agentDir, 'secrets.json'),
|
|
270
|
+
hostdUrl,
|
|
271
|
+
restartToken,
|
|
272
|
+
containerName,
|
|
273
|
+
})
|
|
267
274
|
}
|
|
268
275
|
|
|
269
|
-
function buildKakaotalkSignature(agentDir: string
|
|
270
|
-
const path =
|
|
271
|
-
if (!existsSync(path)) {
|
|
272
|
-
return { signature: '', missing: [`kakaotalk credentials file at ${path}`] }
|
|
273
|
-
}
|
|
276
|
+
function buildKakaotalkSignature(agentDir: string): { signature: string; missing: string[] } {
|
|
277
|
+
const path = join(agentDir, 'secrets.json')
|
|
274
278
|
try {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// (some host filesystems quantize to seconds).
|
|
282
|
-
const buf = readFileSync(path)
|
|
283
|
-
const digest = createHash('sha256').update(buf).digest('hex')
|
|
284
|
-
return { signature: `${path}@sha256:${digest}`, missing: [] }
|
|
279
|
+
const block = new SecretsBackend(path).tryReadChannelsSync()?.kakaotalk
|
|
280
|
+
if (!isKakaoCredentialBlock(block)) {
|
|
281
|
+
return { signature: '', missing: ['secrets.json#channels.kakaotalk'] }
|
|
282
|
+
}
|
|
283
|
+
const digest = createHash('sha256').update(JSON.stringify(block)).digest('hex')
|
|
284
|
+
return { signature: `secrets.json#channels.kakaotalk@sha256:${digest}`, missing: [] }
|
|
285
285
|
} catch (err) {
|
|
286
|
-
return { signature: '', missing: [`kakaotalk
|
|
286
|
+
return { signature: '', missing: [`secrets.json#channels.kakaotalk (${describe(err)})`] }
|
|
287
287
|
}
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
function isKakaoCredentialBlock(value: unknown): value is { accounts: Record<string, unknown> } {
|
|
291
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
292
|
+
if (!('accounts' in value)) return false
|
|
293
|
+
const accounts = value.accounts
|
|
294
|
+
return (
|
|
295
|
+
typeof accounts === 'object' && accounts !== null && !Array.isArray(accounts) && Object.keys(accounts).length > 0
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
290
299
|
function describe(err: unknown): string {
|
|
291
300
|
return err instanceof Error ? err.message : String(err)
|
|
292
301
|
}
|
package/src/cli/channel.ts
CHANGED
|
@@ -336,7 +336,7 @@ function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEv
|
|
|
336
336
|
s.stop('Updated typeclaw.json.')
|
|
337
337
|
break
|
|
338
338
|
case 'secrets':
|
|
339
|
-
s.stop('
|
|
339
|
+
s.stop('Saved credentials to secrets.json.')
|
|
340
340
|
break
|
|
341
341
|
}
|
|
342
342
|
}
|
|
@@ -345,11 +345,11 @@ function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEv
|
|
|
345
345
|
const START_MESSAGES: Record<AddChannelStepEvent['step'], string> = {
|
|
346
346
|
'kakaotalk-auth': 'Logging in to KakaoTalk...',
|
|
347
347
|
config: 'Updating typeclaw.json...',
|
|
348
|
-
secrets: '
|
|
348
|
+
secrets: 'Saving credentials to secrets.json...',
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
function reportKakaotalkAuth(result: KakaotalkAuthResult): string {
|
|
352
|
-
if (result.ok) return 'KakaoTalk credentials saved to
|
|
352
|
+
if (result.ok) return 'KakaoTalk credentials saved to secrets.json.'
|
|
353
353
|
return `KakaoTalk login failed: ${result.reason}`
|
|
354
354
|
}
|
|
355
355
|
|